feat: ���� 3D-�������-��������� � ����� + dev JWT-������ #9

Merged
min merged 14 commits from feat/arrow-pointer into main 2026-05-30 19:41:40 +00:00
23 changed files with 1489 additions and 200 deletions

View File

@ -33,7 +33,12 @@
"react-hooks/exhaustive-deps": "warn", "react-hooks/exhaustive-deps": "warn",
"no-eval": "error", "no-eval": "error",
"no-new-func": "error", "no-new-func": "error",
"no-implied-eval": "error" "no-implied-eval": "error",
"no-empty": "off",
"react/no-unescaped-entities": "off",
"no-useless-catch": "warn",
"no-constant-condition": ["warn", { "checkLoops": false }],
"no-fallthrough": "warn"
}, },
"ignorePatterns": [ "ignorePatterns": [
"build/", "build/",

View File

@ -132,23 +132,27 @@ jobs:
chmod 600 ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts
- name: Install rsync - name: Install rsync
run: apt-get update -qq && apt-get install -y rsync openssh-client run: apt-get update -qq && apt-get install -y rsync openssh-client
# S1 — НЕ блокирующий: при недоступности S1 (downtime) деплой не должен
# валиться, главное доставить на S2. ConnectTimeout 20с чтобы не висеть.
- name: Deploy to S1 (85.175.7.40:1998) - name: Deploy to S1 (85.175.7.40:1998)
continue-on-error: true
run: | run: |
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 1998" \ -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998" \
build/ min@85.175.7.40:/var/www/rublox-player/build/ build/ min@85.175.7.40:/var/www/rublox-player/build/
- name: Deploy to S2 (192.168.0.124:22, runner в той же сети) - name: Deploy to S2 (192.168.0.124:22, runner в той же сети)
run: | run: |
rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \ rsync -az --delete-after --human-readable --exclude=wiki --exclude=kubikon-assets \
-e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \ -e "ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22" \
build/ min@192.168.0.124:/var/www/rublox-player/build/ build/ min@192.168.0.124:/var/www/rublox-player/build/
- name: Verify deploy - name: Verify S1 (не блокирующий)
continue-on-error: true
run: | run: |
echo "=== S1 ===" 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 -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/" "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
echo "=== S2 ===" - name: Verify S2 (обязательный)
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/" "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ public/kubikon-assets/
# OS # OS
Thumbs.db Thumbs.db
.env.production

104
API_USAGE.md Normal file
View File

@ -0,0 +1,104 @@
# API, которые использует плеер
Плеер — это **клиент**. Все данные он берёт с серверных API. Этот документ — полный список эндпоинтов которые он дёргает, зачем и от чего ломается если API изменится.
## Базовый URL
В prod: `https://api.rublox.pro` (alias на `minecraftia-school.ru/api-storys`)
В dev: `https://dev-api.rublox.pro` (staging, см. [reference-staging-env])
Локально: `http://localhost:8674` (storys-микросервис)
## Эндпоинты — список
### Игры и лента
| Method | Path | Когда зовётся | Сломается если... |
|---|---|---|---|
| GET | `/kubikon3d/feed?tab=hot\|new\|popular\|topweek` | при заходе на главную | поле `games[]` отсутствует |
| GET | `/kubikon3d/trending` | главная, секция трендов | возвращает не-массив |
| GET | `/kubikon3d/search?q=<query>` | поиск игр | поле `results[]` отсутствует |
| GET | `/kubikon3d/collections` | главная, секции «Хиты», «Новинки» | возвращает не-массив |
| GET | `/kubikon3d/projects/<id>` | при открытии конкретной игры | поле `project_data` отсутствует или поломан JSON-формат |
| GET | `/kubikon3d/my-projects` | страница «Мои игры» | в ответе нет `projects[]` |
| GET | `/kubikon3d/top-authors` | страница «Топ авторов» | возвращает не-массив |
| GET | `/kubikon3d/events` | главная, баннер мероприятий | возвращает не-массив |
### Игровой процесс (telemetry)
| Method | Path | Когда | Что шлёт |
|---|---|---|---|
| POST | `/kubikon3d/play/heartbeat` | каждые 30 сек пока игрок в игре | `{ game_id, play_time_ms }` |
| POST | `/kubikon3d/activity` | при входе/выходе из игры | `{ game_id, action: 'enter'\|'leave' }` |
| POST | `/kubikon3d/perf-log` | при детектировании просадки FPS <20 на 5+ сек | `{ game_id, fps, draw_calls, mem }` |
| POST | `/kubikon3d/bug-reports` | юзер жмёт «Сообщить о баге» | `{ game_id, description, screenshot_b64 }` |
| POST | `/kubikon3d/reports` | юзер репортит игру | `{ game_id, reason, comment }` |
### Скины и аватары (Рублокс-персонажи)
| Method | Path | Когда |
|---|---|---|
| GET | `/kubikon3d/rublox/equipped-skin/<user_id>` | при спавне аватара любого игрока |
| GET | `/kubikon3d/rublox/owned-skins` | страница «Мои скины» |
| POST | `/kubikon3d/rublox/equipped-skin` | юзер сменил скин |
| GET | `/api-storys/rublox/avatars/<user_id>` | список аватаров юзера |
| GET | `/api-storys/rublox/outfit/<user_id>` | детали одежды |
| GET | `/kubikon-assets/characters/skins_manifest.json` | при загрузке плеера, список всех доступных скинов |
### Эмоушены (R15 анимации)
| Method | Path | Когда |
|---|---|---|
| GET | `/api-storys/rublox/emotes/list` | при заходе в игру (для меню эмоушенов) |
| POST | `/api-storys/rublox/emotes/play/<emote_id>` | юзер выбрал эмоушен |
### Модели и ассеты
| Method | Path | Когда |
|---|---|---|
| GET | `/kubikon3d/models/public` | при загрузке игры — список public GLB-моделей |
| GET | `/kubikon3d/models/mine` | в редакторе (плеер запускает превью своих моделей) |
| GET | `/kubikon3d/models/<id>` | при первом упоминании модели в проекте |
### Админка (только видна юзерам с ролью admin)
Все `/kubikon3d/admin/*` доступны только если в JWT юзер имеет роль `admin`. Иначе бэк возвращает 403.
| Method | Path | Что показывает |
|---|---|---|
| GET | `/kubikon3d/admin/dashboard` | сводка: онлайн, активные игры |
| GET | `/kubikon3d/admin/online` | список онлайн-юзеров |
| GET | `/kubikon3d/admin/all-games` | все игры с фильтрами |
| GET | `/kubikon3d/admin/moderation-queue` | очередь премодерации (для review-игр) |
| GET | `/kubikon3d/admin/reports` | репорты на игры |
| GET | `/kubikon3d/admin/bug-reports` | баг-репорты |
| GET | `/kubikon3d/admin/comments` | модерация комментариев |
| GET | `/kubikon3d/admin/chat/messages` | чат-сообщения |
| GET | `/kubikon3d/admin/chat/bans` | список банов в чате |
| GET | `/kubikon3d/admin/authors` | топ авторов с детальной статистикой |
### Multiplayer (Colyseus)
WebSocket-соединение, не HTTP. Адрес: `wss://multiplayer.rublox.pro/<room_id>`. Сейчас работает через `kubikon-realtime` микросервис (VM 110, port 8685, Node.js+Colyseus+Redis).
## Аутентификация
Все приватные эндпоинты ожидают **JWT в заголовке** `Authorization: Bearer <token>`. JWT выдаёт `/api-user/auth/login`. Срок жизни access-токена — 1 час, refresh-токена — 30 дней.
В плеере токен хранится в `localStorage.jwt`, рефрешится автоматически при 401 через `localStorage.refresh_token``/api-user/auth/refresh`.
## Изменения API
**До publish:** если меняешь сигнатуру эндпоинта (убираешь поле, переименовываешь, меняешь тип) — это **breaking change**. Объявление за 48 часов в канале `#разработка` на https://team.rublox.pro.
Записывай в `API_CHANGELOG.md` админ-репо (приватный, `minecraftia-school.ru/...`) — это позволяет отследить какие изменения когда были.
## Что делать если эндпоинт пропал
1. Открой issue в репо плеера: «API endpoint /xxx not found».
2. Прикрепи console-лог с ошибкой.
3. Кто-то из core-команды посмотрит в `API_CHANGELOG.md` админ-репо и ответит — это было умышленное изменение или баг.
## Контакты
- Issue tracker: https://git.rublox.pro/rublox/player/issues
- Чат: `#разработка` на https://team.rublox.pro

View File

@ -1,31 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: {
// Стилевые правила — не валим CI на осознанном код-стиле движка
// (пустые catch для тихого проглатывания ошибок, нестрогие var'ы).
'no-empty': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-constant-condition': ['warn', { checkLoops: false }],
'no-fallthrough': 'warn',
'no-useless-catch': 'warn',
'react-refresh/only-export-components': 'off',
},
},
])

View File

@ -34,7 +34,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --max-warnings 200", "lint": "eslint . --ext .js,.jsx --max-warnings 200",
"format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"", "format": "prettier --write \"src/**/*.{js,jsx,json,md,css}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"", "format:check": "prettier --check \"src/**/*.{js,jsx,json,md,css}\"",
"fetch-assets": "node scripts/fetch-assets.js", "fetch-assets": "node scripts/fetch-assets.js",

View File

@ -38,7 +38,8 @@ function PlayerRoute() {
return ( return (
<LoadingScreen <LoadingScreen
text="Нужен JWT" text="Нужен JWT"
subText={`Положи токен в localStorage["player_jwt"] и перезагрузи страницу. Это dev-fallback, на проде такого экрана нет — там сразу редирект на rublox.pro. (gameId=${id})`} subText={`Это dev-fallback (только localhost). На проде сразу редирект на rublox.pro. (gameId=${id})`}
devJwt
/> />
); );
} }

View File

@ -1217,6 +1217,17 @@ function TabReport({ gameId, gameTitle }) {
setStatus({ text: 'Отправка...', error: false }); setStatus({ text: 'Отправка...', error: false });
try { try {
const token = getToken(); const token = getToken();
// Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и
// принимает поле text. Раньше TabReport слал {title, message,
// game_id, game_title} БЕЗ reporter_user_id бэк отвечал
// 400 'reporter_user_id required' жалоба падала. Приводим к
// формату бэкенда (как нижняя кнопка «Жалоба»).
const me = getMyProfile();
if (!me || !me.id) {
setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true });
setSending(false);
return;
}
const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, { const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -1224,11 +1235,12 @@ function TabReport({ gameId, gameTitle }) {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
reporter_user_id: me.id,
target_type: 'project',
target_id: gameId || null,
category, category,
title: title.trim(), text: title.trim() + '\n\n' + message.trim()
message: message.trim(), + (gameTitle ? `\n\n(игра: ${gameTitle})` : ''),
game_id: gameId || null,
game_title: gameTitle || null,
}), }),
}); });
if (res.ok) { if (res.ok) {
@ -1236,7 +1248,9 @@ function TabReport({ gameId, gameTitle }) {
setTitle(''); setTitle('');
setMessage(''); setMessage('');
} else { } else {
setStatus({ text: 'Не удалось отправить. Попробуйте позже.', error: true }); let detail = '';
try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {}
setStatus({ text: 'Не удалось отправить' + detail + '.', error: true });
} }
} catch (e) { } catch (e) {
setStatus({ text: 'Сеть недоступна.', error: true }); setStatus({ text: 'Сеть недоступна.', error: true });

View File

@ -363,6 +363,10 @@ const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false,
is_manual: data.is_manual, is_manual: data.is_manual,
}); });
setError(data.message || formatMuteMessage(data)); setError(data.message || formatMuteMessage(data));
} else if (code === 'email_not_confirmed') {
// То же поведение что и в WS-пути: русская модалка «подтвердите
// email», а не сырой английский код ошибки.
setEmailNotice(true);
} else if (code === 'too_frequent') { } else if (code === 'too_frequent') {
setError(data.message || 'Слишком быстро.'); setError(data.message || 'Слишком быстро.');
} else if (code === 'login_required') { } else if (code === 'login_required') {

View File

@ -505,22 +505,26 @@ const KubikonPlayer = () => {
}); });
scene.setOnPlayChange?.((playing) => { scene.setOnPlayChange?.((playing) => {
setIsPlaying(playing); setIsPlaying(playing);
// ESC обрабатывается через pointerlockchange-перехват в плеере // ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем ESC теперь
// (см. отдельный useEffect ниже). Сюда мы попадаем только если // открывает меню через setOnEscMenu (ниже), не выходя из Play.
// exitPlayMode вызвался по другой причине тогда просто открываем // exitPlayMode(false) случается только по-настоящему (напр. движок
// меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play // сам остановил Play). В этом случае просто открываем меню, чтобы
// в UI-cursor режиме. // юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически
// повторный enterPlayMode респавнил игрока и перезапускал скрипты
// («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой.
if (!playing) { if (!playing) {
setTimeout(() => { const s = sceneRef.current;
const s = sceneRef.current; s?.player?.setUiCursorMode?.(true);
if (!s) return;
s.enterPlayMode?.();
s.player?.setUiCursorMode?.(true);
}, 30);
setChatOpen(false); setChatOpen(false);
setTopMenuOpen(true); setTopMenuOpen(true);
} }
}); });
// ESC в Play меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не
// прерывается, скрипты продолжают идти, игрок не респавнится.
scene.setOnEscMenu?.(() => {
setChatOpen(false);
setTopMenuOpen(true);
});
// Загружаем проект. // Загружаем проект.
// STANDALONE-режим (VITE_STANDALONE=true) подсовывает встроенный // STANDALONE-режим (VITE_STANDALONE=true) подсовывает встроенный
@ -711,15 +715,25 @@ const KubikonPlayer = () => {
const s = sceneRef.current; const s = sceneRef.current;
if (!s || !s._isPlaying) return; if (!s || !s._isPlaying) return;
const locked = !!document.pointerLockElement; const locked = !!document.pointerLockElement;
// Lock потерян, мы НЕ в UI-cursor mode пользователь нажал ESC if (locked || !s.player || s.player._uiCursorMode) return;
if (!locked && s.player && !s.player._uiCursorMode) { // Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание
// Синхронно ставим флаг listener PlayerController сработает // ПКМ (orbit-камера) тоже снимает lock это НЕ выход в меню.
// следующим и увидит true, не вызовет _onExitRequest. // Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим:
s.player._uiCursorMode = true; // first/lockfirst/sideview/shiftLock) там потеря lock = реальный ESC.
// Открываем меню в следующий тик (state-update React) const p = s.player;
setChatOpen(false); const permaLock = (
setTopMenuOpen(true); p._cameraMode === 'first' ||
} p._cameraMode === 'lockfirst' ||
p._cameraMode === 'sideview' ||
p._shiftLock
);
// _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third
// это orbit-завершение, не меню.
if (!permaLock) return;
// Реальный ESC в perma-режиме открываем меню.
p._uiCursorMode = true;
setChatOpen(false);
setTopMenuOpen(true);
}; };
// capture-фаза, чтобы успеть раньше PlayerController // capture-фаза, чтобы успеть раньше PlayerController
document.addEventListener('pointerlockchange', onLockChange, true); document.addEventListener('pointerlockchange', onLockChange, true);

View File

@ -9,11 +9,62 @@
// //
// CSS-анимации, без JS-фрейма каждый кадр. // CSS-анимации, без JS-фрейма каждый кадр.
import React from 'react'; import React, { useState } from 'react';
const TITLE = 'Рублокс'; const TITLE = 'Рублокс';
export default function LoadingScreen({ text = 'Подключение', subText = null }) { /**
* Dev-only панель вставки JWT. Показывается на экране «Нужен JWT» (только
* localhost). Кнопка инпут сохраняет в localStorage['player_jwt'] и
* перезагружает страницу. На проде этот экран не наступает (там redirect).
*/
function DevJwtPanel() {
const [open, setOpen] = useState(false);
const [val, setVal] = useState('');
const apply = () => {
const t = (val || '').trim();
if (!t) return;
try {
localStorage.setItem('player_jwt', t);
// совместимость с другими местами чтения токена
localStorage.setItem('Authorization', t);
} catch (e) { /* ignore */ }
window.location.reload();
};
if (!open) {
return (
<button className="rb-devbtn" onClick={() => setOpen(true)} title="Вставить JWT (dev)">
🔑 Вставить JWT
</button>
);
}
return (
<div className="rb-devpanel">
<textarea
className="rb-devinput"
placeholder="Вставь сюда player_jwt…"
value={val}
onChange={(e) => setVal(e.target.value)}
autoFocus
spellCheck={false}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) apply(); }}
/>
<div className="rb-devrow">
<button className="rb-devapply" onClick={apply} disabled={!val.trim()}>
Войти
</button>
<button className="rb-devcancel" onClick={() => setOpen(false)}>
Отмена
</button>
</div>
<div className="rb-devhint">Ctrl+Enter войти. Токен сохранится в localStorage.</div>
</div>
);
}
export default function LoadingScreen({ text = 'Подключение', subText = null, devJwt = false }) {
return ( return (
<div className="rb-splash"> <div className="rb-splash">
<style>{splashCss}</style> <style>{splashCss}</style>
@ -47,6 +98,9 @@ export default function LoadingScreen({ text = 'Подключение', subText
{text}<span className="rb-dots" aria-hidden="true"></span> {text}<span className="rb-dots" aria-hidden="true"></span>
</div> </div>
{subText && <div className="rb-substatus">{subText}</div>} {subText && <div className="rb-substatus">{subText}</div>}
{/* Dev-only: вставка JWT прямо с экрана (вместо ручного localStorage). */}
{devJwt && <DevJwtPanel />}
</div> </div>
); );
} }
@ -127,6 +181,51 @@ const splashCss = `
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
} }
/* === Dev JWT panel === */
.rb-devbtn {
margin-top: 22px;
background: rgba(255,255,255,0.14);
border: 1px solid rgba(255,255,255,0.3);
color: #fff;
font-size: 13px; font-weight: 600;
padding: 8px 16px; border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.rb-devbtn:hover { background: rgba(255,255,255,0.24); }
.rb-devpanel {
margin-top: 20px;
width: 380px; max-width: calc(100vw - 40px);
display: flex; flex-direction: column; gap: 8px;
}
.rb-devinput {
width: 100%; height: 70px; resize: vertical;
background: rgba(0,0,0,0.28);
border: 1px solid rgba(255,255,255,0.3);
border-radius: 10px;
color: #fff; font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
padding: 10px; box-sizing: border-box;
outline: none;
}
.rb-devinput:focus { border-color: rgba(255,255,255,0.6); }
.rb-devrow { display: flex; gap: 8px; }
.rb-devapply {
flex: 1;
background: #ffffff; color: #2841C8;
border: none; border-radius: 10px;
font-size: 14px; font-weight: 700;
padding: 9px 0; cursor: pointer;
}
.rb-devapply:disabled { opacity: 0.4; cursor: default; }
.rb-devcancel {
background: transparent; color: rgba(255,255,255,0.8);
border: 1px solid rgba(255,255,255,0.3); border-radius: 10px;
font-size: 13px; padding: 9px 16px; cursor: pointer;
}
.rb-devhint {
font-size: 11px; opacity: 0.6; text-align: center;
}
@keyframes rbBubble { @keyframes rbBubble {
0%, 100% { opacity: 0.35; } 0%, 100% { opacity: 0.35; }
50% { opacity: 0.70; } 50% { opacity: 0.70; }

View File

@ -132,7 +132,7 @@ export default function PreviewAvatarRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -214,7 +214,7 @@ export default function PreviewEmoteRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -208,7 +208,7 @@ export default function PreviewModelRoute() {
</span> </span>
)} )}
</div> </div>
<button onClick={() => { try { window.close(); } catch (e) {}; navigate('/'); }} <button onClick={() => { try { window.close(); } catch (e) {} navigate('/'); }}
style={closeBtnStyle}>Закрыть</button> style={closeBtnStyle}>Закрыть</button>
</div> </div>

View File

@ -91,7 +91,7 @@ export function readTicketFromHash() {
export function readTeamJwtFromHash() { export function readTeamJwtFromHash() {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
// JWT-формат: header.payload.signature — три blob'а из base64url, точки. // JWT-формат: header.payload.signature — три blob'а из base64url, точки.
const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)/ const m = /(?:^|[#&])team_jwt=([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/
.exec(window.location.hash || ''); .exec(window.location.hash || '');
return m ? m[1] : null; return m ? m[1] : null;
} }

View File

@ -313,7 +313,7 @@ const EMOJI_TO_NAME = {
'◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves', '◣': 'prim-wedge', '◢': 'prim-cornerwedge', '〰': 'waves',
// UI / художественные // UI / художественные
'🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image', '🎨': 'palette', '📺': 'monitor', '🖼': 'image', '🖼️': 'image',
'🔤': 'type', '🟧': 'square', '🔤': 'type',
// звук // звук
'🎵': 'music', '🎼': 'music2', '🔊': 'sound', '🎵': 'music', '🎼': 'music2', '🔊': 'sound',
// навигация // навигация

View File

@ -2183,8 +2183,12 @@ export class BabylonScene {
const onMouseDown = (e) => { const onMouseDown = (e) => {
if (this._isPlaying) { if (this._isPlaying) {
// В Play-режиме ЛКМ — клик игрока в forward-направлении. // В Play-режиме ЛКМ — клик игрока в forward-направлении.
// Pointer Lock — курсор всё равно в центре экрана. // При pointer-lock курсор в центре; в third (свободный курсор)
if (e.button === 0) this._handlePlayClick(); // передаём реальные координаты клика для pick по табличкам.
if (e.button === 0) {
const r = canvas.getBoundingClientRect();
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
}
return; return;
} }
// Обновляем pointer координаты для raycast и Gizmo // Обновляем pointer координаты для raycast и Gizmo
@ -2913,7 +2917,7 @@ export class BabylonScene {
* - в self-обработчики скриптов (routeEvent с target) * - в self-обработчики скриптов (routeEvent с target)
* - в глобальные обработчики (game.onClick) с event.target * - в глобальные обработчики (game.onClick) с event.target
*/ */
_handlePlayClick() { _handlePlayClick(clickX, clickY) {
if (!this._isPlaying) return; if (!this._isPlaying) return;
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
@ -2934,6 +2938,40 @@ export class BabylonScene {
} }
if (!this.gameRuntime) return; if (!this.gameRuntime) return;
// === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) ===
// При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем
// из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам
// клика (clickX/clickY переданы из onMouseDown). Без этого клик по
// табличке мышью в third промахивался — кнопки не нажимались.
if (this.billboardUiManager && this.primitiveManager) {
const locked = (document.pointerLockElement === this.canvas);
const w = this.engine?.getRenderWidth?.() || this.canvas.width;
const h = this.engine?.getRenderHeight?.() || this.canvas.height;
const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2);
const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2);
const bpick = this.scene.pick(px, py, (m) =>
m && m.metadata && m.metadata.primitiveId != null
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard');
if (bpick && bpick.hit && bpick.pickedMesh) {
const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId);
const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null;
if (bdata && uv) {
const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y);
console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId
+ ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId
+ ' locked=' + locked);
if (buttonId) {
this.billboardUiManager.fireClick(bdata, buttonId);
return; // клик по табличке обработан
}
} else {
console.log('[billboard] попал в табличку id='
+ bpick.pickedMesh.metadata.primitiveId + ' но нет UV');
}
}
}
const pick = this._pickFromCenter(); const pick = this._pickFromCenter();
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
@ -5305,6 +5343,17 @@ export class BabylonScene {
this.modalManager.close(); this.modalManager.close();
return; return;
} }
// ESC в плеере = открыть меню-оверлей поверх ЖИВОЙ игры (как в Roblox).
// Раньше тут был exitPlayMode() + _onPlayChange(false), из-за чего
// KubikonPlayer заново звал enterPlayMode → игра перезапускалась
// (респавн + перезапуск скриптов). Теперь только UI-курсор + сигнал
// открыть меню. Play продолжает идти под меню.
if (typeof this._onEscMenu === 'function') {
this.player?.setUiCursorMode?.(true);
this._onEscMenu();
return;
}
// Фолбэк (если меню не подписано, напр. в студии) — старое поведение.
this.exitPlayMode(); this.exitPlayMode();
if (this._onPlayChange) this._onPlayChange(false); if (this._onPlayChange) this._onPlayChange(false);
}); });
@ -5496,6 +5545,8 @@ export class BabylonScene {
// === Лучи и следы (Фаза 5.2 — Beam/Trail) === // === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
if (!this.beamManager) this.beamManager = new BeamManager(this); if (!this.beamManager) this.beamManager = new BeamManager(this);
this.beamManager.start(); this.beamManager.start();
// Задача 08: активируем pointer-примитивы из палитры в реальные стрелки.
this._activatePointers();
// === 3D-звук (Фаза 5.5 — позиционный звук) === // === 3D-звук (Фаза 5.5 — позиционный звук) ===
if (!this.soundManager) this.soundManager = new SoundManager(this); if (!this.soundManager) this.soundManager = new SoundManager(this);
@ -5886,6 +5937,16 @@ export class BabylonScene {
this._onPlayChange = cb; this._onPlayChange = cb;
} }
/**
* Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры
* БЕЗ выхода из Play. Если подписан ESC не делает exitPlayMode (см.
* setOnExitRequest в enterPlayMode). В студии не подписывается там ESC
* по-прежнему выходит из Play.
*/
setOnEscMenu(cb) {
this._onEscMenu = cb;
}
/** /**
* Колбэк изменения сцены (любая модификация блоков/моделей). * Колбэк изменения сцены (любая модификация блоков/моделей).
* Используется KubikonEditor для dirty-tracking auto-save. * Используется KubikonEditor для dirty-tracking auto-save.
@ -5933,6 +5994,18 @@ export class BabylonScene {
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
} }
/** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */
_setHotbarVisible(visible) {
this._hotbarVisible = !!visible;
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
}
/** Скрыть/показать только HP-индикатор (полоска жизней). */
_setHpVisible(visible) {
this._hpVisible = !!visible;
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
}
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
setOnCursorModeChange(cb) { setOnCursorModeChange(cb) {
@ -7131,6 +7204,35 @@ export class BabylonScene {
this._syncUserModelColliders(); this._syncUserModelColliders();
} }
// === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера ===
// ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе
// PlayerController прочитает старый _playerModelType (баг: пончик 2046
// не ставился — skins.default применялся ниже, после предзагрузки).
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g');
// форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
}
// Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') {
this._skinsConfig = {
default: state.scene.skins.default || null,
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
shopVisible: state.scene.skins.shopVisible !== false,
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
};
// Стартовый скин из skins.default имеет приоритет над playerModelType.
if (this._skinsConfig.default) {
const d = this._skinsConfig.default;
this._playerModelType = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:'))
? d : ('skin_' + d);
}
} else {
this._skinsConfig = null;
}
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
// PlayerController.start() её ждёт, но если предзагрузить сейчас, // PlayerController.start() её ждёт, но если предзагрузить сейчас,
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно. // на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
@ -7228,36 +7330,7 @@ export class BabylonScene {
} }
} }
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
// Тип модели персонажа. // (Тип модели персонажа и skins решены выше — до предзагрузки модели.)
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g').
// Теперь стандарт — R15-скин bacon-hair. Если в проекте старая
// Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
if (pmt.startsWith('character-')) {
this._playerModelType = 'skin_bacon-hair';
} else {
this._playerModelType = pmt;
}
}
// Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') {
this._skinsConfig = {
default: state.scene.skins.default || null,
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
shopVisible: state.scene.skins.shopVisible !== false,
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
};
// Стартовый скин из skins.default имеет приоритет над playerModelType.
if (this._skinsConfig.default) {
const d = this._skinsConfig.default;
this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')
? d : ('skin_' + d);
}
} else {
this._skinsConfig = null;
}
// Пользовательские скрипты // Пользовательские скрипты
if (Array.isArray(state.scene.scripts)) { if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts this._scripts = state.scene.scripts
@ -7290,6 +7363,49 @@ export class BabylonScene {
} }
} }
/**
* Задача 08: активировать pointer-примитивы из палитры в реальные стрелки.
* Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка
* (лента + парящий quest-marker) от источника к цели. from/to из инспектора.
*/
_activatePointers() {
const pm = this.primitiveManager;
const bm = this.beamManager;
if (!pm || !bm) return;
for (const inst of pm.instances.values()) {
if (inst.type !== 'pointer') continue;
try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {}
const at = { x: inst.x, y: inst.y, z: inst.z };
const from = this._pointerRefOrPoint(inst.pointerFrom, at);
const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 });
try {
bm.addPointer({
from, to,
preset: inst.pointerPreset || 'guide',
color: inst.color, textureSpeed: inst.textureSpeed,
curved: inst.curved, curveHeight: inst.curveHeight,
});
} catch (e) {
console.warn('[BabylonScene] addPointer failed:', e);
}
}
}
/** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */
_pointerRefOrPoint(val, fallbackPoint) {
if (val === 'player') return 'player';
if (val != null && val !== '') {
const n = Number(val);
if (Number.isFinite(n)) {
if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n;
if (this.modelManager?.instances?.has(n)) return 'model:' + n;
}
if (typeof val === 'string'
&& (val.startsWith('primitive:') || val.startsWith('model:'))) return val;
}
return fallbackPoint;
}
/** Выйти из режима игры — восстановить редактор-камеру. */ /** Выйти из режима игры — восстановить редактор-камеру. */
exitPlayMode() { exitPlayMode() {
if (!this._isPlaying) return; if (!this._isPlaying) return;

View File

@ -1,30 +1,50 @@
/** /**
* BeamManager лучи (Beam) и следы (Trail) как объекты сцены (Фаза 5.2). * BeamManager лучи (Beam), следы (Trail) и стрелки-указатели (Pointer)
* как объекты сцены.
* *
* Beam светящаяся линия между двумя точками. Точки могут быть * Beam светящаяся линия/лента между двумя точками. Точки могут быть
* фиксированными координатами или ref объектов тогда луч * фиксированными координатами, ref объектов ИЛИ 'player' тогда
* следует за объектами каждый кадр (лазеры, мосты света, * конец следует за объектом/игроком каждый кадр.
* соединения, цепи). * Trail шлейф за движущимся объектом (Babylon TrailMesh).
* Trail шлейф, тянущийся за движущимся объектом (Babylon TrailMesh). * Pointer высокоуровневая «стрелка иди сюда»: текстурированная лента
* (бегущие шевроны/стрелки), с пресетами, curved-дугой, градиентом.
* *
* Живут только в Play-режиме. Управляются скриптом через game.fx.* * Задача 08: расширены опции addBeam (texture/textureSpeed/curved/colorSequence/
* каждый вызов возвращает прокси-объект. * faceMode/strokeColor/...) + game.fx.pointer. Живут только в Play-режиме
* (и в превью редактора со скоростью анимации 0).
*/ */
import { import {
MeshBuilder, StandardMaterial, Color3, Vector3, MeshBuilder, StandardMaterial, Color3, Color4, Vector3,
DynamicTexture, Texture, VertexBuffer, TransformNode,
} from '@babylonjs/core'; } from '@babylonjs/core';
import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh'; import { TrailMesh } from '@babylonjs/core/Meshes/trailMesh';
let _fxIdSeq = 1; let _fxIdSeq = 1;
// Кэш сгенерированных текстур по ключу (форма+обводка) — одна на сцену.
// Текстуры белые (рисуются белым), реальный цвет даёт mat.emissiveColor/diffuse.
const _texCache = new Map();
// Встроенные пресеты для game.fx.pointer — разворачиваются в опции beam.
const POINTER_PRESETS = {
guide: { texture: 'chevron', color: '#ff3a3a', strokeColor: '#000000', strokeWidth: 4, textureSpeed: 3, width: 0.9, textureScale: 1 },
quest: { texture: 'chevron', color: '#ffd23a', strokeColor: '#5a3a00', strokeWidth: 3, textureSpeed: 3.5, width: 0.9, textureScale: 1 },
danger: { texture: 'lightning', color: '#ff2a2a', strokeColor: '#3a0000', strokeWidth: 2, textureSpeed: 5, width: 1.0, textureScale: 1.2 },
gift: { texture: 'sparkle', color: '#ffffff', strokeColor: null, strokeWidth: 0, textureSpeed: 2, width: 1.0, textureScale: 1.4,
colorSequence: [{ p: 0, c: '#ff5a5a' }, { p: 0.25, c: '#ffd23a' }, { p: 0.5, c: '#5aff7a' }, { p: 0.75, c: '#3a9aff' }, { p: 1, c: '#c45aff' }] },
};
export class BeamManager { export class BeamManager {
constructor(scene3d) { constructor(scene3d) {
this.scene3d = scene3d; this.scene3d = scene3d;
this.scene = scene3d.scene; this.scene = scene3d.scene;
/** @type {Map<number, object>} id → fx state (beam | trail) */ /** @type {Map<number, object>} id → fx state (beam | trail | pointer) */
this.items = new Map(); this.items = new Map();
this._renderHook = null; this._renderHook = null;
this._lastTime = 0;
// В превью редактора (не Play) анимацию текстур замораживаем.
this.animationEnabled = true;
} }
start() { start() {
@ -46,59 +66,304 @@ export class BeamManager {
try { try {
if (it.mesh) it.mesh.dispose(); if (it.mesh) it.mesh.dispose();
if (it.mat) it.mat.dispose(); if (it.mat) it.mat.dispose();
// Парящий quest-marker: root (TransformNode) с дочерним конусом —
// dispose с потомками убирает и конус, и его outline.
if (it._markerMesh) it._markerMesh.dispose();
if (it._marker) it._marker.dispose();
if (it._markerMat) it._markerMat.dispose();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
// ===================================================================
// ТЕКСТУРЫ ЛУЧА — генерируются на лету через Canvas2D (без PNG-файлов).
// Все белые с альфа-каналом; цвет даёт материал. strokeColor рисуется
// под основной формой.
// ===================================================================
/** Получить (с кэшем) DynamicTexture для формы. */
_getBeamTexture(shape, strokeColor, strokeWidth) {
const key = shape + '|' + (strokeColor || '') + '|' + (strokeWidth || 0);
if (_texCache.has(key)) return _texCache.get(key);
const S = 128;
const dyn = new DynamicTexture('beamTex_' + key, { width: S, height: S }, this.scene, true);
dyn.hasAlpha = true;
dyn.wrapU = Texture.WRAP_ADDRESSMODE;
dyn.wrapV = Texture.CLAMP_ADDRESSMODE;
const ctx = dyn.getContext();
ctx.clearRect(0, 0, S, S);
this._drawShape(ctx, shape, S, strokeColor, strokeWidth);
dyn.update();
_texCache.set(key, dyn);
return dyn;
}
/** /**
* Создать луч между двумя точками. * Рисует форму на canvas. Ориентация: текстура натягивается вдоль ленты
* opts: { from, to {x,y,z} или ref-строка объекта; * так, что U идёт ПО длине (fromto). Шеврон рисуем «>» указывающим в
* color: '#hex', width: толщина (м) }. * сторону +U (к цели).
*/
_drawShape(ctx, shape, S, strokeColor, strokeWidth) {
const sw = Number(strokeWidth) || 0;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
const drawPath = (pathFn, fill) => {
// Обводка (под формой) — рисуем толще тем же путём.
if (strokeColor && sw > 0) {
ctx.beginPath(); pathFn();
ctx.strokeStyle = strokeColor;
ctx.lineWidth = sw * 2 + 14;
ctx.stroke();
}
ctx.beginPath(); pathFn();
if (fill) {
ctx.fillStyle = '#ffffff';
ctx.fill();
} else {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 16;
ctx.stroke();
}
};
const m = S * 0.18; // отступ
const cx = S / 2, cy = S / 2;
switch (shape) {
case 'chevron': {
// «>» указывающий вправо (к цели вдоль +U)
drawPath(() => {
ctx.moveTo(m, m);
ctx.lineTo(S - m, cy);
ctx.lineTo(m, S - m);
}, false);
break;
}
case 'arrow': {
// наконечник-треугольник вправо
drawPath(() => {
ctx.moveTo(m, m + 6);
ctx.lineTo(S - m, cy);
ctx.lineTo(m, S - m - 6);
ctx.closePath();
}, true);
break;
}
case 'dot': {
drawPath(() => { ctx.arc(cx, cy, S * 0.22, 0, Math.PI * 2); }, true);
break;
}
case 'line': {
ctx.fillStyle = '#ffffff';
if (strokeColor && sw > 0) {
ctx.fillStyle = strokeColor;
ctx.fillRect(0, cy - (S * 0.28) / 2 - sw, S, S * 0.28 + sw * 2);
ctx.fillStyle = '#ffffff';
}
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
break;
}
case 'dash': {
ctx.fillStyle = '#ffffff';
ctx.fillRect(S * 0.12, cy - (S * 0.22) / 2, S * 0.76, S * 0.22);
break;
}
case 'wave': {
drawPath(() => {
ctx.moveTo(0, cy);
for (let x = 0; x <= S; x += 4) {
ctx.lineTo(x, cy + Math.sin((x / S) * Math.PI * 2) * (S * 0.28));
}
}, false);
break;
}
case 'lightning': {
drawPath(() => {
ctx.moveTo(m, m);
ctx.lineTo(cx + 6, cy - 6);
ctx.lineTo(cx - 6, cy + 6);
ctx.lineTo(S - m, S - m);
}, false);
break;
}
case 'sparkle': {
// 4-лучевая звёздочка
drawPath(() => {
const r = S * 0.30, ri = S * 0.10;
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2 - Math.PI / 2;
const rad = (i % 2 === 0) ? r : ri;
const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
}, true);
break;
}
default: { // fallback — сплошная линия
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, cy - (S * 0.28) / 2, S, S * 0.28);
}
}
}
// ===================================================================
// BEAM (расширенный — задача 08)
// ===================================================================
/**
* Создать луч/ленту между двумя точками.
* opts: {
* from, to {x,y,z} | ref-строка | 'player';
* color, width;
* texture: 'chevron'|'arrow'|'dot'|'line'|'wave'|'lightning'|'dash'|'sparkle'|'custom'|null;
* customTextureUrl, textureMode, textureSpeed, textureScale,
* strokeColor, strokeWidth,
* colorSequence:[{p,c}], transparencySequence:[{p,t}], widthSequence:[{p,w}],
* faceMode: 'billboard'|'flat-x'|'flat-y'|'flat-z',
* segments, curved, curveHeight, attachOffset:{fromY,toY},
* ignoreDepth,
* }
* Возвращает id. * Возвращает id.
*/ */
addBeam(opts = {}) { addBeam(opts = {}) {
const id = _fxIdSeq++; const id = _fxIdSeq++;
const width = Number.isFinite(opts.width) ? opts.width : 0.15; const hasTexture = !!opts.texture && opts.texture !== 'none';
const mat = new StandardMaterial('beamMat_' + id, this.scene); const it = {
const col = Color3.FromHexString(opts.color || '#66ccff'); id, type: 'beam',
from: opts.from, to: opts.to,
width: Number.isFinite(opts.width) ? opts.width : (hasTexture ? 0.8 : 0.15),
color: opts.color || '#66ccff',
texture: hasTexture ? opts.texture : null,
customTextureUrl: opts.customTextureUrl || null,
textureMode: opts.textureMode || 'wrap',
textureSpeed: Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 0,
textureScale: Number.isFinite(opts.textureScale) ? opts.textureScale : 1,
strokeColor: opts.strokeColor || null,
strokeWidth: Number.isFinite(opts.strokeWidth) ? opts.strokeWidth : 3,
colorSequence: Array.isArray(opts.colorSequence) ? opts.colorSequence : null,
transparencySequence: Array.isArray(opts.transparencySequence) ? opts.transparencySequence : null,
widthSequence: Array.isArray(opts.widthSequence) ? opts.widthSequence : null,
faceMode: opts.faceMode || 'billboard',
segments: Math.max(2, Number.isFinite(opts.segments) ? opts.segments : 24),
curved: !!opts.curved,
curveHeight: Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2,
attachOffset: opts.attachOffset || { fromY: 0, toY: 0 },
ignoreDepth: opts.ignoreDepth !== false, // по умолчанию рисуем поверх
uOffset: 0,
mesh: null, mat: null,
};
this._buildBeamMaterial(it);
this.items.set(id, it);
this._updateBeam(it);
return id;
}
_buildBeamMaterial(it) {
if (it.mat) { try { it.mat.dispose(); } catch (e) {} }
const mat = new StandardMaterial('beamMat_' + it.id, this.scene);
const col = Color3.FromHexString(it.color);
mat.diffuseColor = col; mat.diffuseColor = col;
mat.emissiveColor = col; mat.emissiveColor = col;
mat.disableLighting = true; mat.disableLighting = true;
// Цилиндр-заготовка единичной высоты — масштабируем под длину луча. mat.backFaceCulling = false;
const mesh = MeshBuilder.CreateCylinder('beam_' + id, if (it.texture) {
{ height: 1, diameter: width, tessellation: 8 }, this.scene); let tex;
mesh.material = mat; if (it.texture === 'custom' && it.customTextureUrl) {
mesh.isPickable = false; tex = new Texture(it.customTextureUrl, this.scene);
mesh.renderingGroupId = 1; tex.hasAlpha = true;
const it = { tex.wrapU = Texture.WRAP_ADDRESSMODE;
id, type: 'beam', mesh, mat, tex.wrapV = Texture.CLAMP_ADDRESSMODE;
from: opts.from, to: opts.to, } else {
}; tex = this._getBeamTexture(it.texture, it.strokeColor, it.strokeWidth);
this.items.set(id, it); }
this._updateBeam(it); // сразу позиционируем mat.diffuseTexture = tex;
return id; mat.emissiveTexture = tex;
mat.opacityTexture = tex; // альфа формы
mat.useAlphaFromDiffuseTexture = true;
}
mat.alpha = it.texture ? 1 : 0.9;
if (it.ignoreDepth) {
mat.disableDepthWrite = true;
// рисуем поверх геометрии
}
it.mat = mat;
} }
/** Сменить цвет луча. */ /** Сменить цвет луча. */
setBeamColor(id, color) { setBeamColor(id, color) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it || it.type !== 'beam' || !it.mat) return; if (!it || !it.mat || !color) return;
const col = Color3.FromHexString(color || '#66ccff'); it.color = color;
const col = Color3.FromHexString(color);
it.mat.diffuseColor = col; it.mat.diffuseColor = col;
it.mat.emissiveColor = col; it.mat.emissiveColor = col;
this._recolorMarker(it, col);
// если есть градиент — он перекрывает; иначе vertexColors обновим в _updateBeam
it.colorSequence = null;
} }
/** Сменить концы луча (координаты или ref). */ /** Перекрасить quest-marker в цвет луча (при смене пресета). */
_recolorMarker(it, col) {
if (it && it._markerMat && col) {
it._markerMat.diffuseColor = col;
it._markerMat.emissiveColor = col;
}
}
/** Сменить концы луча (координаты | ref | 'player'). */
setBeamEndpoints(id, from, to) { setBeamEndpoints(id, from, to) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it || it.type !== 'beam') return; if (!it) return;
if (from !== undefined) it.from = from; if (from !== undefined) it.from = from;
if (to !== undefined) it.to = to; if (to !== undefined) it.to = to;
} }
/** Обновить произвольные опции луча на лету (для pointer.update). */
updateBeam(id, opts = {}) {
const it = this.items.get(Number(id));
if (!it) return;
let rebuild = false;
for (const k of ['texture', 'strokeColor', 'strokeWidth', 'customTextureUrl', 'ignoreDepth']) {
if (opts[k] !== undefined && opts[k] !== it[k]) { it[k] = opts[k]; rebuild = true; }
}
for (const k of ['color', 'width', 'textureMode', 'textureSpeed', 'textureScale',
'faceMode', 'segments', 'curved', 'curveHeight', 'attachOffset',
'colorSequence', 'transparencySequence', 'widthSequence']) {
if (opts[k] !== undefined) it[k] = opts[k];
}
if (opts.from !== undefined) it.from = opts.from;
if (opts.to !== undefined) it.to = opts.to;
if (rebuild) {
this._buildBeamMaterial(it);
} else if (opts.color !== undefined && !it.colorSequence) {
const col = Color3.FromHexString(it.color);
it.mat.diffuseColor = col; it.mat.emissiveColor = col;
}
// Перекрасить quest-marker под новый пресет. Для градиента (gift) —
// средний цвет последовательности.
if (it._markerMat) {
let mc = it.color;
if (it.colorSequence && it.colorSequence.length) {
mc = this._sampleSeqColor(it.colorSequence, 0.5) || it.color;
}
if (mc) this._recolorMarker(it, Color3.FromHexString(mc));
}
// геометрия (curved/segments/width) пересоберётся в _updateBeam — сбросим mesh
if (opts.curved !== undefined || opts.segments !== undefined
|| opts.width !== undefined || opts.widthSequence !== undefined
|| opts.colorSequence !== undefined) {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} it.mesh = null; }
}
this._updateBeam(it);
}
setVisible(id, vis) {
const it = this.items.get(Number(id));
if (it && it.mesh) it.mesh.setEnabled(!!vis);
if (it && it._marker) it._marker.setEnabled(!!vis); // и маркер
if (it) it._hidden = !vis;
}
/** /**
* Создать шлейф за объектом. * Создать шлейф за объектом. (без изменений Фаза 5.2)
* ref ref-строка объекта. opts: { color, width, lifetime (сек) }.
* Возвращает id.
*/ */
addTrail(ref, opts = {}) { addTrail(ref, opts = {}) {
const data = this._resolve(ref); const data = this._resolve(ref);
@ -107,11 +372,8 @@ export class BeamManager {
const id = _fxIdSeq++; const id = _fxIdSeq++;
const width = Number.isFinite(opts.width) ? opts.width : 0.4; const width = Number.isFinite(opts.width) ? opts.width : 0.4;
const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5; const lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 1.5;
// TrailMesh(name, generator, scene, diameter, length, autoStart).
// length — сколько сегментов хранить; считаем из lifetime (≈60 fps).
const segments = Math.max(10, Math.round(lifetime * 60)); const segments = Math.max(10, Math.round(lifetime * 60));
const trail = new TrailMesh('trail_' + id, mesh, this.scene, const trail = new TrailMesh('trail_' + id, mesh, this.scene, width, segments, true);
width, segments, true);
const mat = new StandardMaterial('trailMat_' + id, this.scene); const mat = new StandardMaterial('trailMat_' + id, this.scene);
const col = Color3.FromHexString(opts.color || '#ffcc44'); const col = Color3.FromHexString(opts.color || '#ffcc44');
mat.diffuseColor = col; mat.diffuseColor = col;
@ -126,7 +388,7 @@ export class BeamManager {
return id; return id;
} }
/** Убрать луч/след по id. */ /** Убрать луч/след/указатель по id. */
remove(id) { remove(id) {
const it = this.items.get(Number(id)); const it = this.items.get(Number(id));
if (!it) return; if (!it) return;
@ -138,41 +400,380 @@ export class BeamManager {
_tick() { _tick() {
if (this.items.size === 0) return; if (this.items.size === 0) return;
const now = performance.now() / 1000;
let dt = this._lastTime ? (now - this._lastTime) : 0.016;
this._lastTime = now;
if (dt > 0.1) dt = 0.016; // защита от больших скачков (вкладка спала)
for (const it of this.items.values()) { for (const it of this.items.values()) {
// Trail обновляется самим Babylon (autoStart). Beam — мы. if (it.type === 'trail') continue; // Babylon сам
if (it.type === 'beam') this._updateBeam(it); if (it._hidden) continue;
this._updateBeam(it);
// Анимация бегущей текстуры.
if (this.animationEnabled && it.texture && it.textureSpeed
&& it.mat && it.mat.diffuseTexture && it.mat.diffuseTexture.uOffset !== undefined) {
it.uOffset -= it.textureSpeed * dt * 0.4;
it.mat.diffuseTexture.uOffset = it.uOffset;
}
// Парящий 3D-маркер над целью: позиция + подпрыгивание + вращение.
if (it._marker) this._tickMarker(it, now);
} }
} }
/** Анимировать quest-marker над целью (bob + spin), держать над to. */
_tickMarker(it, now) {
const root = it._marker;
if (!root) return;
// Цель: точка `to` (без attachOffset — нам нужна верхушка объекта).
const tgt = this._point(it.to, 0);
if (!tgt) { root.setEnabled(false); return; }
root.setEnabled(true);
const ph = it._markerPhase || 0;
// bob: плавный синус ±0.22м; высота над целью ~2.2м.
const bob = Math.sin(now * 3 + ph) * 0.22;
root.position.set(tgt.x, tgt.y + 2.2 + bob, tgt.z);
// Вращение всего маркера вокруг Y (дочерний конус остаётся остриём вниз).
root.rotation.y = now * 1.6 + ph;
}
/**
* Построить/обновить геометрию луча. Для текстурированных/curved
* ribbon из сегментов с UV вдоль длины. Для простого цилиндр (легаси).
*/
_updateBeam(it) { _updateBeam(it) {
const a = this._point(it.from); const a = this._point(it.from, it.attachOffset && it.attachOffset.fromY);
const b = this._point(it.to); const b = this._point(it.to, it.attachOffset && it.attachOffset.toY);
if (!a || !b || !it.mesh) return; if (!a || !b) {
if (it.mesh) it.mesh.setEnabled(false);
return;
}
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
const len = Math.hypot(dx, dy, dz); const len = Math.hypot(dx, dy, dz);
if (len < 0.001) { it.mesh.setEnabled(false); return; } if (len < 0.01) { if (it.mesh) it.mesh.setEnabled(false); return; }
// Текстурированный/curved/градиент — лента (ribbon).
if (it.texture || it.curved || it.colorSequence || it.widthSequence) {
this._updateRibbon(it, a, b, len);
return;
}
// Простой луч — легаси цилиндр.
if (!it.mesh || it._meshKind !== 'cyl') {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
it.mesh = MeshBuilder.CreateCylinder('beam_' + it.id,
{ height: 1, diameter: it.width, tessellation: 8 }, this.scene);
it.mesh.material = it.mat;
it.mesh.isPickable = false;
it.mesh.renderingGroupId = 1;
it._meshKind = 'cyl';
}
it.mesh.setEnabled(true); it.mesh.setEnabled(true);
// Цилиндр единичной высоты вдоль локальной оси Y. Растягиваем по длине.
it.mesh.scaling.y = len; it.mesh.scaling.y = len;
// Центр луча.
it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2); it.mesh.position.set((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
// Ориентируем ось Y цилиндра вдоль вектора a→b.
const dir = new Vector3(dx, dy, dz).normalize(); const dir = new Vector3(dx, dy, dz).normalize();
// yaw + pitch так, чтобы +Y смотрел вдоль dir.
const yaw = Math.atan2(dir.x, dir.z); const yaw = Math.atan2(dir.x, dir.z);
const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y))); const pitch = Math.acos(Math.max(-1, Math.min(1, dir.y)));
it.mesh.rotation.set(pitch, yaw, 0); it.mesh.rotation.set(pitch, yaw, 0);
} }
/** Точка из {x,y,z} или ref-строки объекта. */ /**
_point(p) { * Построить ленту (ribbon): две линии вершин вдоль направления fromto,
* смещённые перпендикулярно (billboard к камере). UV: u вдоль длины
* (количество тайлов = длина/ширина × textureScale), v поперёк.
*/
_updateRibbon(it, a, b, len) {
const seg = it.curved ? it.segments : 1;
const A = new Vector3(a.x, a.y, a.z);
const B = new Vector3(b.x, b.y, b.z);
// центральная линия (curved = квадратичная Безье через приподнятый midpoint)
const center = [];
if (it.curved) {
const mid = A.add(B).scale(0.5);
mid.y += it.curveHeight;
for (let i = 0; i <= seg; i++) {
const t = i / seg;
const omt = 1 - t;
// B(t) = (1-t)^2 A + 2(1-t)t M + t^2 B
const x = omt * omt * A.x + 2 * omt * t * mid.x + t * t * B.x;
const y = omt * omt * A.y + 2 * omt * t * mid.y + t * t * B.y;
const z = omt * omt * A.z + 2 * omt * t * mid.z + t * t * B.z;
center.push(new Vector3(x, y, z));
}
} else {
center.push(A, B);
}
// направление «вбок» для ширины: billboard → перпендикуляр к лучу и к камере.
const cam = this.scene.activeCamera;
const camPos = cam ? cam.position : new Vector3(0, 50, 0);
const widthAt = (t) => {
if (it.widthSequence && it.widthSequence.length) {
return this._sampleSeq(it.widthSequence, t, 'w', it.width);
}
return it.width;
};
const left = [], right = [];
for (let i = 0; i < center.length; i++) {
const p = center[i];
const t = center.length > 1 ? i / (center.length - 1) : 0;
// касательная к линии
const prev = center[Math.max(0, i - 1)];
const next = center[Math.min(center.length - 1, i + 1)];
const tangent = next.subtract(prev);
if (tangent.lengthSquared() < 1e-6) tangent.copyFrom(B.subtract(A));
tangent.normalize();
let side;
if (it.faceMode === 'flat-y') {
side = new Vector3(0, 1, 0).cross(tangent); // лежит горизонтально
} else if (it.faceMode === 'flat-x') {
side = new Vector3(1, 0, 0);
} else if (it.faceMode === 'flat-z') {
side = new Vector3(0, 0, 1);
} else { // billboard — перпендикуляр к лучу, в плоскости к камере
const toCam = camPos.subtract(p);
side = tangent.cross(toCam);
}
if (side.lengthSquared() < 1e-6) side = new Vector3(1, 0, 0);
side.normalize().scaleInPlace(widthAt(t) / 2);
left.push(p.add(side));
right.push(p.subtract(side));
}
const pathArray = [left, right];
// UV вдоль длины: тайлов = длина / ширина × scale
const tiles = Math.max(1, Math.round((len / Math.max(0.1, it.width)) * 0.6 * it.textureScale));
// (пере)создание ribbon. updatable, чтобы менять каждый кадр дёшево.
const needNew = !it.mesh || it._meshKind !== 'ribbon' || it._segCount !== center.length;
if (needNew) {
if (it.mesh) { try { it.mesh.dispose(); } catch (e) {} }
// sideOrientation НЕ DOUBLESIDE: при DOUBLESIDE Babylon удваивает
// вершины (фронт+бэк), а наши UV/vertexColors считаются на 2n вершин
// → setVerticesData выходит за vertex buffer → GL_INVALID_OPERATION
// «Vertex buffer is not big enough» (стрелка не рисовалась, WebGL
// отрубал контекст). Двусторонность даёт материал (backFaceCulling
// = false), DOUBLESIDE-геометрия не нужна.
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
pathArray, updatable: true,
}, this.scene);
it.mesh.material = it.mat;
it.mesh.isPickable = false;
it.mesh.renderingGroupId = it.ignoreDepth ? 3 : 1;
it._meshKind = 'ribbon';
it._segCount = center.length;
this._applyRibbonUV(it, center.length, tiles);
this._applyRibbonColors(it, center.length);
} else {
it.mesh = MeshBuilder.CreateRibbon('beam_' + it.id, {
pathArray, instance: it.mesh,
});
// UV/colors при изменении длины тайлов
if (it._tiles !== tiles) { this._applyRibbonUV(it, center.length, tiles); }
}
it._tiles = tiles;
it.mesh.setEnabled(true);
}
/** UV: u = тайлы вдоль длины (для wrap-повтора), v = 0..1 поперёк. */
_applyRibbonUV(it, n, tiles) {
if (!it.mesh) return;
const uv = [];
// pathArray=[left,right] → вершины идут: left[0..n-1], right[0..n-1]
for (let i = 0; i < n; i++) {
const u = (i / (n - 1 || 1)) * tiles;
uv.push(u, 0);
}
for (let i = 0; i < n; i++) {
const u = (i / (n - 1 || 1)) * tiles;
uv.push(u, 1);
}
try { it.mesh.setVerticesData(VertexBuffer.UVKind, uv, true); } catch (e) {}
}
/** Градиент цвета/прозрачности вдоль длины через vertexColors. */
_applyRibbonColors(it, n) {
if (!it.mesh) return;
if (!it.colorSequence && !it.transparencySequence) {
// равномерный цвет — vertexColors не нужны (материал даёт цвет)
return;
}
const colors = [];
const base = Color3.FromHexString(it.color);
const sample = (t) => {
let c = base;
if (it.colorSequence) {
const hex = this._sampleSeqColor(it.colorSequence, t);
if (hex) c = Color3.FromHexString(hex);
}
const alpha = it.transparencySequence
? 1 - this._sampleSeq(it.transparencySequence, t, 't', 0)
: 1;
return new Color4(c.r, c.g, c.b, alpha);
};
for (let i = 0; i < n; i++) {
const t = i / (n - 1 || 1);
const c = sample(t);
colors.push(c.r, c.g, c.b, c.a);
}
for (let i = 0; i < n; i++) {
const t = i / (n - 1 || 1);
const c = sample(t);
colors.push(c.r, c.g, c.b, c.a);
}
try {
it.mesh.setVerticesData(VertexBuffer.ColorKind, colors, true);
it.mat.useVertexColor = true;
if (it.transparencySequence) it.mat.alpha = 1; // альфа из vertexColor
} catch (e) {}
}
_sampleSeq(seq, t, key, def) {
if (!seq || !seq.length) return def;
let prev = seq[0], next = seq[seq.length - 1];
for (let i = 0; i < seq.length; i++) {
if (seq[i].p <= t) prev = seq[i];
if (seq[i].p >= t) { next = seq[i]; break; }
}
if (prev === next || next.p === prev.p) return prev[key];
const f = (t - prev.p) / (next.p - prev.p);
return prev[key] + (next[key] - prev[key]) * f;
}
_sampleSeqColor(seq, t) {
if (!seq || !seq.length) return null;
let prev = seq[0], next = seq[seq.length - 1];
for (let i = 0; i < seq.length; i++) {
if (seq[i].p <= t) prev = seq[i];
if (seq[i].p >= t) { next = seq[i]; break; }
}
const ca = Color3.FromHexString(prev.c), cb = Color3.FromHexString(next.c);
if (prev === next || next.p === prev.p) return prev.c;
const f = (t - prev.p) / (next.p - prev.p);
const r = Math.round((ca.r + (cb.r - ca.r) * f) * 255);
const g = Math.round((ca.g + (cb.g - ca.g) * f) * 255);
const b = Math.round((ca.b + (cb.b - ca.b) * f) * 255);
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
// ===================================================================
// POINTER (game.fx.pointer) — высокоуровневая стрелка-указатель
// ===================================================================
/**
* Создать стрелку-указатель. opts: { from, to, preset, ...override }.
* from/to: {x,y,z} | ref | 'player'. preset: guide|quest|danger|gift|custom.
* Возвращает id (это beam с pointer-флагом).
*/
addPointer(opts = {}) {
const preset = POINTER_PRESETS[opts.preset] || (opts.preset === 'custom' ? {} : POINTER_PRESETS.guide);
const merged = {
from: opts.from,
to: opts.to,
...preset,
// явные override из opts перебивают пресет:
...(opts.color !== undefined ? { color: opts.color } : {}),
...(opts.texture !== undefined ? { texture: opts.texture } : {}),
...(opts.textureSpeed !== undefined ? { textureSpeed: opts.textureSpeed } : {}),
...(opts.strokeColor !== undefined ? { strokeColor: opts.strokeColor } : {}),
...(opts.colorSequence !== undefined ? { colorSequence: opts.colorSequence } : {}),
...(opts.curved !== undefined ? { curved: opts.curved } : {}),
...(opts.curveHeight !== undefined ? { curveHeight: opts.curveHeight } : {}),
...(opts.faceMode !== undefined ? { faceMode: opts.faceMode } : {}),
...(opts.customTextureUrl !== undefined ? { texture: 'custom', customTextureUrl: opts.customTextureUrl } : {}),
// Указатель — горизонтальная лента (flat-y), лежит над землёй и
// всегда видна сверху. billboard на curved-ленте вырождался (side-
// вектор tangent×toCam схлопывался на сегментах смотрящих на камеру)
// → стрелка пропадала. flat-y держит ширину стабильно.
faceMode: opts.faceMode || 'flat-y',
curved: opts.curved !== undefined ? opts.curved : true,
curveHeight: opts.curveHeight !== undefined ? opts.curveHeight : 0.8,
// Лента над землёй, цель — у её центра. Хорошо видно, не в земле.
width: opts.width !== undefined ? opts.width : 1.4,
attachOffset: opts.attachOffset || { fromY: 1.6, toY: 1.2 },
};
const id = this.addBeam(merged);
const it = this.items.get(id);
if (it) {
it._isPointer = true;
// Парящая 3D-стрелка над целью (как quest-marker в Roblox):
// объёмный конус вершиной ВНИЗ, подпрыгивает + вращается + светится.
this._makePointerMarker(it);
}
return id;
}
/**
* 3D-маркер над целью (Roblox quest-pin): гладкий конус остриём ВНИЗ,
* светится, с чёрной обводкой. Анимация (bob + spin) на РОДИТЕЛЕ
* (TransformNode), сам конус ориентирован вниз статично, поэтому spin
* не ломает «вниз» направление и обводка не съезжает.
*/
_makePointerMarker(it) {
try {
const col = Color3.FromHexString(it.color || '#ff3a3a');
// Родитель: только позиция/bob/spin. Дочерний конус — геометрия.
const root = new TransformNode('ptrMarkerRoot_' + it.id, this.scene);
// Гладкий конус (tessellation 18 — не пирамида). Остриё вниз:
// CreateCylinder остриём ВВЕРХ (diameterTop=0), поворот X=PI → вниз.
const m = MeshBuilder.CreateCylinder('ptrMarker_' + it.id, {
height: 1.0, diameterTop: 0, diameterBottom: 0.55, tessellation: 18,
}, this.scene);
m.parent = root;
m.rotation.x = Math.PI; // остриём вниз (на цель)
m.isPickable = false;
m.renderingGroupId = 3; // поверх геометрии (как beam)
const mat = new StandardMaterial('ptrMarkerMat_' + it.id, this.scene);
mat.diffuseColor = col;
mat.emissiveColor = col; // светится
mat.disableLighting = true;
mat.disableDepthWrite = true; // рисуем поверх
m.material = mat;
// Мультяшная обводка — встроенный Babylon outline (не второй меш,
// потому не съезжает). Толщину даём заметную.
m.renderOutline = true;
m.outlineColor = new Color3(0, 0, 0);
m.outlineWidth = 0.06;
it._marker = root;
it._markerMesh = m;
it._markerMat = mat;
it._markerPhase = (it.id % 7) * 0.5; // разный фазовый сдвиг bob
} catch (e) {
console.warn('[BeamManager] marker create failed:', e);
}
}
/** Сменить цель указателя. */
setPointerTarget(id, to) {
const it = this.items.get(Number(id));
if (it) it.to = to;
}
/** Применить пресет к существующему указателю (для pointer.update({preset})). */
applyPointerPreset(id, preset) {
const p = POINTER_PRESETS[preset];
if (!p) return;
this.updateBeam(id, p);
}
// ===================================================================
/** Точка из {x,y,z} | ref | 'player'. yOff — доп. смещение по Y. */
_point(p, yOff) {
const off = Number.isFinite(yOff) ? yOff : 0;
if (!p) return null; if (!p) return null;
if (p === 'player') {
const pl = this.scene3d && this.scene3d.player;
if (pl && pl._pos) return { x: pl._pos.x, y: pl._pos.y + off, z: pl._pos.z };
return null;
}
if (typeof p === 'object' && Number.isFinite(p.x)) { if (typeof p === 'object' && Number.isFinite(p.x)) {
return { x: p.x, y: p.y, z: p.z }; return { x: p.x, y: p.y + off, z: p.z };
} }
if (typeof p === 'string') { if (typeof p === 'string') {
const d = this._resolve(p); const d = this._resolve(p);
if (d) return { x: d.x, y: d.y, z: d.z }; if (d) return { x: d.x, y: d.y + off, z: d.z };
} }
return null; return null;
} }

View File

@ -43,7 +43,7 @@ function normName(raw) {
return String(raw || '') return String(raw || '')
.toLowerCase() .toLowerCase()
.replace(/mixamorig/g, '') .replace(/mixamorig/g, '')
.replace(/[:_\s.\-]/g, ''); .replace(/[:_\s.-]/g, '');
} }
function resolveLogicalR15(boneName) { function resolveLogicalR15(boneName) {

View File

@ -1556,6 +1556,17 @@ export class GameRuntime {
id = bm.addBeam({ id = bm.addBeam({
from: payload.from, to: payload.to, from: payload.from, to: payload.to,
color: payload.color, width: payload.width, color: payload.color, width: payload.width,
// Задача 08: расширенные опции луча.
texture: payload.texture, customTextureUrl: payload.customTextureUrl,
textureMode: payload.textureMode, textureSpeed: payload.textureSpeed,
textureScale: payload.textureScale,
strokeColor: payload.strokeColor, strokeWidth: payload.strokeWidth,
colorSequence: payload.colorSequence,
transparencySequence: payload.transparencySequence,
widthSequence: payload.widthSequence,
faceMode: payload.faceMode, segments: payload.segments,
curved: payload.curved, curveHeight: payload.curveHeight,
attachOffset: payload.attachOffset, ignoreDepth: payload.ignoreDepth,
}); });
} else if (payload.kind === 'trail') { } else if (payload.kind === 'trail') {
id = bm.addTrail(payload.ref, { id = bm.addTrail(payload.ref, {
@ -1577,6 +1588,53 @@ export class GameRuntime {
if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color); if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color);
return; return;
} }
// === Задача 08: стрелка-указатель + расширенное управление лучом ===
if (cmd === 'fx.createPointer') {
const bm = this.scene3d?.beamManager;
if (bm && payload) {
const id = bm.addPointer({
from: payload.from, to: payload.to, preset: payload.preset,
color: payload.color, texture: payload.texture,
customTextureUrl: payload.customTextureUrl,
textureSpeed: payload.textureSpeed, width: payload.width,
strokeColor: payload.strokeColor, colorSequence: payload.colorSequence,
curved: payload.curved, curveHeight: payload.curveHeight,
faceMode: payload.faceMode, attachOffset: payload.attachOffset,
});
if (id == null) {
this._log('error', 'не удалось создать стрелку-указатель');
} else if (payload.localRef) {
if (!this._fxLocalToReal) this._fxLocalToReal = new Map();
this._fxLocalToReal.set(payload.localRef, id);
}
}
return;
}
if (cmd === 'fx.pointerTarget') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) this.scene3d?.beamManager?.setPointerTarget(fid, payload?.to);
return;
}
if (cmd === 'fx.pointerUpdate') {
const fid = this._resolveFxId(payload?.ref);
const bm = this.scene3d?.beamManager;
if (fid != null && bm) {
const o = payload?.opts || {};
if (o.preset) bm.applyPointerPreset(fid, o.preset);
bm.updateBeam(fid, o);
}
return;
}
if (cmd === 'fx.beamUpdate') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) this.scene3d?.beamManager?.updateBeam(fid, payload?.opts || {});
return;
}
if (cmd === 'fx.beamVisible') {
const fid = this._resolveFxId(payload?.ref);
if (fid != null) this.scene3d?.beamManager?.setVisible(fid, payload?.visible !== false);
return;
}
if (cmd === 'fx.beamEndpoints') { if (cmd === 'fx.beamEndpoints') {
const fid = this._resolveFxId(payload?.ref); const fid = this._resolveFxId(payload?.ref);
if (fid != null) { if (fid != null) {
@ -1962,6 +2020,14 @@ export class GameRuntime {
} catch (e) {} } catch (e) {}
return; return;
} }
if (cmd === 'hud.setHotbarVisible') {
try { this.scene3d?._setHotbarVisible?.(!!payload?.visible); } catch (e) {}
return;
}
if (cmd === 'hud.setHpVisible') {
try { this.scene3d?._setHpVisible?.(!!payload?.visible); } catch (e) {}
return;
}
if (cmd === 'input.setCursorMode') { if (cmd === 'input.setCursorMode') {
try { try {
const mode = payload?.mode === 'ui' ? 'ui' : 'game'; const mode = payload?.mode === 'ui' ? 'ui' : 'game';

View File

@ -150,6 +150,9 @@ export class PlayerController {
this._lockFirstPerson = false; this._lockFirstPerson = false;
// Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере. // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере.
this._shiftLock = false; this._shiftLock = false;
// Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора.
this._rmbHeld = false;
this._mouseIconVisible = true;
// Ввод // Ввод
this._codes = new Set(); this._codes = new Set();
@ -395,10 +398,28 @@ export class PlayerController {
this._beforeRender = () => this._tick(); this._beforeRender = () => this._tick();
this.scene.registerBeforeRender(this._beforeRender); this.scene.registerBeforeRender(this._beforeRender);
// Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock
// отклониться с SecurityError если предыдущий lock ещё не отпущен — // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно —
// в этом случае ждём отпускания и пробуем снова. // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ.
this._requestPointerLockSafe(); if (this._isPermaLockMode()) {
this._requestPointerLockSafe();
}
this._applyCursorVisibility();
}
/** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */
_isPermaLockMode() {
return this._cameraMode === 'first' || this._cameraMode === 'lockfirst'
|| this._cameraMode === 'sideview' || this._shiftLock;
}
/** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в
* first/lock скрыт. Учитывает game.input.setMouseIconVisible. */
_applyCursorVisibility() {
if (!this.canvas) return;
const locked = (document.pointerLockElement === this.canvas);
const show = (this._mouseIconVisible !== false) && !locked;
try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ }
} }
/** /**
@ -632,45 +653,51 @@ export class PlayerController {
*/ */
async _loadSkinManifest() { async _loadSkinManifest() {
if (this._skinManifest) return this._skinManifest; if (this._skinManifest) return this._skinManifest;
// 2026-05-27: сначала пробуем БД (rublox_avatars), там и легаси и // ВАЖНО: объединяем ОБА источника, а не «или-или».
// дизайнерские аватары после approve. Только при сетевой ошибке — // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался
// fallback на статичный manifest.json. // ТОЛЬКО он, а статичный skins_manifest.json (где встроенные
// non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut
// и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback
// на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF
// (Unexpected magic) → старая модель уже выгружена, новая не создаётся →
// скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары.
let combined = [];
// 1) Статичный JSON (встроенные скины, включая non-humanoid).
try {
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
if (resp.ok) {
const json = await resp.json();
if (Array.isArray(json.skins)) combined = combined.concat(json.skins);
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e);
}
// 2) БД rublox_avatars (легаси + дизайнерские аватары после approve).
try { try {
const resp = await fetch(_storysApiUrl('/rublox/avatars')); const resp = await fetch(_storysApiUrl('/rublox/avatars'));
if (resp.ok) { if (resp.ok) {
const json = await resp.json(); const json = await resp.json();
const items = json.items || []; const items = json.items || [];
// Нормализуем под формат старого manifest: // Нормализуем: file уже полный путь (absolute_file=true), т.к.
// {id, file (без /kubikon-assets/ префикса), overrides} // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс.
// — потому что _resolveModelSource дальше добавляет const avatars = items.map((a) => ({
// '/kubikon-assets/' + entry.file.
// Дизайнерский file_path может быть /api-storys/... — оставляем
// как есть и добавляем спец-флаг entry.absolute_file=true,
// _resolveModelSource учтёт.
this._skinManifest = items.map((a) => ({
id: a.code, id: a.code,
name: a.name, name: a.name,
file: a.file_path, file: a.file_path,
overrides: a.overrides || {}, overrides: a.overrides || {},
absolute_file: true, // file уже полный путь, не resolve через /kubikon-assets/ absolute_file: true,
})); }));
if (this._skinManifest.length > 0) return this._skinManifest; // Аватары имеют приоритет при совпадении id — кладём в начало.
const avatarIds = new Set(avatars.map((a) => a.id));
combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id)));
} }
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[PlayerController] /rublox/avatars failed, fallback to manifest.json:', e); console.warn('[PlayerController] /rublox/avatars failed:', e);
} }
// Fallback на статичный JSON this._skinManifest = combined;
try { return combined;
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
const json = await resp.json();
this._skinManifest = json.skins || [];
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] skins_manifest load failed:', e);
this._skinManifest = [];
}
return this._skinManifest;
} }
/** /**
@ -2191,10 +2218,23 @@ export class PlayerController {
_setupInput() { _setupInput() {
const canvas = this.canvas; const canvas = this.canvas;
// Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock.
const needPermLock = () => (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
const onCanvasClick = () => { const onCanvasClick = () => {
// В UI-режиме клик по канвасу НЕ перехватывает мышь // В UI-режиме клик не перехватывает мышь.
if (this._uiCursorMode) return; if (this._uiCursorMode) return;
if (this._active && document.pointerLockElement !== canvas) { if (!this._active) return;
// Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся
// свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов
// где курсор постоянно скрыт, и только если lock был снят.
if (!needPermLock()) return;
if (document.pointerLockElement !== canvas) {
try { try {
const p = canvas.requestPointerLock?.(); const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {}); if (p && typeof p.catch === 'function') p.catch(() => {});
@ -2203,6 +2243,34 @@ export class PlayerController {
}; };
canvas.addEventListener('click', onCanvasClick); canvas.addEventListener('click', onCanvasClick);
// === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
// Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся.
const onCanvasMouseDownGlobal = (e) => {
if (!this._active || this._uiCursorMode) return;
if (e.button !== 2) return; // только ПКМ
if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает
this._rmbHeld = true;
if (document.pointerLockElement !== canvas) {
try {
const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (err) { /* ignore */ }
}
e.preventDefault();
};
const onWindowMouseUpGlobal = (e) => {
if (e.button !== 2) return;
if (!this._rmbHeld) return;
this._rmbHeld = false;
if (needPermLock()) return;
if (document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
}
};
canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
window.addEventListener('mouseup', onWindowMouseUpGlobal);
canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); });
// === UI-режим: mousedown / mouseup → callback (для drag-игр) === // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
const onCanvasMouseDown = (e) => { const onCanvasMouseDown = (e) => {
if (!this._uiCursorMode) return; if (!this._uiCursorMode) return;
@ -2254,15 +2322,38 @@ export class PlayerController {
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
// Колесо в 3rd-person — меняет дистанцию // Задача 02: колесо = зум third-камеры с авто-переходом third↔first.
const onWheel = (e) => { const onWheel = (e) => {
if (!this._active) return; if (!this._active) return;
// Задача 04: модал с freezeCamera — колесо не зумит. if (this._cameraFrozen) { e.preventDefault(); return; } // модал
if (this._cameraFrozen) { e.preventDefault(); return; } if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD
if (this._cameraMode !== 'third') return; // В first зум наружу возвращает в third (если не lockfirst).
this._thirdDistance += Math.sign(e.deltaY) * 0.5; if (this._cameraMode === 'first') {
if (e.deltaY > 0 && !this._lockFirstPerson) {
this._cameraMode = 'third';
this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5;
if (!this._isPermaLockMode() && document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
}
this._applyCursorVisibility();
this._applyCameraMode?.();
}
e.preventDefault();
return;
}
if (this._cameraMode !== 'third') { e.preventDefault(); return; }
// Экспоненциальный шаг (плавнее вблизи).
this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15);
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN; if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
// Зум внутрь до порога → авто-переход в first (Roblox-style).
const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7;
if (this._thirdDistance <= THRESH) {
this._cameraMode = 'first';
this._requestPointerLockSafe();
this._applyCursorVisibility();
this._applyCameraMode?.();
}
e.preventDefault(); e.preventDefault();
}; };
canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('wheel', onWheel, { passive: false });
@ -2270,12 +2361,26 @@ export class PlayerController {
let wasLocked = false; let wasLocked = false;
const onPointerLockChange = () => { const onPointerLockChange = () => {
const locked = document.pointerLockElement === canvas; const locked = document.pointerLockElement === canvas;
this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор
if (locked) { if (locked) {
wasLocked = true; wasLocked = true;
this._rmbHeld = true; // если попали в lock — ПКМ удерживается
} else if (wasLocked && this._active) { } else if (wasLocked && this._active) {
// Если мы САМИ переключились в UI-cursor mode — не выходим из Play // pointer-lock снят. Причин три:
if (this._uiCursorMode) return; // 1) пользователь в UI-режиме (game.input.setCursorMode('ui'))
if (this._onExitRequest) this._onExitRequest(); // 2) ПКМ отпущена в third-person (orbit-камера завершена)
// 3) Esc → выход из Play (если был в first/lockfirst/sideview/shift)
wasLocked = false;
this._rmbHeld = false;
if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; }
if (needPermLock()) {
// Был режим с постоянным lock'ом и его сняли (Esc) → выход.
if (this._onExitRequest) this._onExitRequest();
} else {
// Third-person: просто отпустили ПКМ. Остаёмся в Play,
// курсор вернулся — это НЕ повод открывать меню.
this._applyCursorVisibility?.();
}
} }
}; };
document.addEventListener('pointerlockchange', onPointerLockChange); document.addEventListener('pointerlockchange', onPointerLockChange);

109
src/engine/README.md Normal file
View File

@ -0,0 +1,109 @@
# Движок плеера Рублокса
Это движок, который **запускает** игры созданные в [студии](https://studio.rublox.pro). В отличие от студии, плеер **только проигрывает** — не редактирует.
## Чем плеер отличается от студии
Обе используют **общий код движка** (BlockManager, PhysicsWorld, ScriptSandbox и т.д.) — это исторически наследие, движок был выделен в студию когда плеер уже существовал. Сейчас репозитории отдельные, но движок один и тот же на 90%.
**Что есть в плеере и нет в студии:**
- `PlayerAuth` через ticket-flow (см. `src/auth/`)
- Polling статуса игры (опубликована / в премодерации / заблокирована)
- Heartbeat для метрик `/kubikon3d/play/heartbeat`
- Чат внутри игры
- Лидерборд по очкам
- Кнопка «Сообщить о баге» (`/kubikon3d/bug-reports`)
- Кнопка «Пожаловаться» (`/kubikon3d/reports`)
**Что есть в студии и нет в плеере:**
- Редактирование сцены (gizmo, кисти, инспектор)
- Сохранение `PUT /projects/<id>`
- Загрузка моделей юзера
- Скрипт-редактор Monaco
- CAD-редактор моделей
- Геймдиз-инструменты (Geometry Dash sub-app)
## Файлы
| Файл | Что |
|---|---|
| `BabylonScene.js` | Главный класс — Engine + Scene + Camera. Тот же что в студии но без gizmo и SelectionManager. |
| `BlockManager.js` | Блоки 1×1×1 на InstancedMesh. **Read-only режим** — не позволяет ставить новые блоки в runtime (только скрипты через `game.scene.spawn`). |
| `PrimitiveManager.js` | Сферы/кубы/цилиндры. Так же read-only. |
| `ModelManager.js` | GLB-модели (мечи, машины, NPC-скины). |
| `PhysicsWorld.js` | AABB-физика. **Всегда включена** в плеере (в студии — только в Play-режиме). |
| `PlayerController.js` | Управление игроком, WASD + Space + мышь. |
| `ScriptSandbox.js` + `ScriptSandboxWorker.js` | Песочница скриптов юзера. |
| `GameRuntime.js` | Оркестратор игрового режима (см. студию). |
| `MultiplayerSync.js` | Colyseus state-sync для онлайн-игр. |
| `AccessoryManager.js` | Шляпы, очки, аксессуары на R15-скелете. |
| `EmoteGlbParser.js` | Парсер GLB-анимаций (танцы, эмоушены). |
## Поток загрузки игры
```
1. Юзер открывает https://player.rublox.pro/<game_id>
2. PlayerAuth проверяет JWT (или redeem ticket из #ticket=)
3. fetch GET /kubikon3d/projects/<game_id>
→ возвращает project_data JSON ~100КБ-5МБ
4. BabylonScene создаёт сцену
BlockManager.loadFromProject(data.blocks)
PrimitiveManager.loadFromProject(data.primitives)
ModelManager.loadFromProject(data.models)
...
5. ScriptSandbox.startAll() — запускает все скрипты юзера
6. GameRuntime.start() — включает физику, ввод
7. Игрок играет
8. Каждые 30с: POST /kubikon3d/play/heartbeat { game_id, play_time_ms }
```
## Что НЕ трогать (опасные оптимизации)
Те же грабли что в студии:
- `scene.blockMaterialDirtyMechanism = true` — ломает новые меши (трейсеры, debris)
- `scene.createOrUpdateSelectionOctree()` в hot path — O(N²) лагает
- `game.ui.set()` в `onTick` без throttle — React setState 60Hz убивает FPS
- `findOne` на старте скрипта — sceneSnapshot приходит через rAF, ref будет null
Подробнее: [docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md)
## AdminPreview/
Папка `src/AdminPreview/` — каталоги ассетов (gdSkins, gdPortals, gdSfx, gdMusic и т.д.). Используются движком при загрузке игр GD-формата (`GdLevelManager`). Контрибьюторам обычно трогать не нужно.
## Производительность — ориентиры
Те же что в студии:
| Объект | Норм | Лагать начинает |
|---|---|---|
| Блоки | 50К | 200К+ |
| Примитивы | 500 | 2000+ |
| GLB-модели | 200 | 500+ (зависит от вершин) |
| NPC | 50 | 100+ |
| Активные скрипты | 30 | 100+ |
| Частицы | 5К | 20К+ |
**FPS-цель плеера выше чем у студии** — игроки чувствительнее к лагам чем создатели:
- 60 FPS на средних ноутбуках 2020+
- 30 FPS на школьных машинках 2015+
- 60 FPS на мобиле (отдельная задача — мобильная оптимизация)
## Связанные доки
- [../../docs/TUTORIAL_FIRST_PR.md](../../docs/TUTORIAL_FIRST_PR.md) — первый PR
- [../../docs/TUTORIAL_DEBUG_BABYLON.md](../../docs/TUTORIAL_DEBUG_BABYLON.md) — отладка
- [../../API_USAGE.md](../../API_USAGE.md) — какие эндпоинты плеер дёргает
- В студии: [studio/src/editor/engine/README.md](https://git.rublox.pro/rublox/studio/src/branch/main/src/editor/engine/README.md) — полное описание движка
## Вопросы
Канал `#разработка` на https://team.rublox.pro

View File

@ -112,6 +112,24 @@ let _guiSubmitHandlers = {};
// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. // сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'.
let _billboardClickHandlers = {}; let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми // Для GUI-события с реальным id вернуть набор ключей, под которыми
// Нормализовать точку для fx.beam/fx.pointer перед postMessage.
// game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через
// postMessage (structured clone бросает DataCloneError → весь скрипт молча
// падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref
// в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть.
function _normFxPoint(p) {
if (p == null) return p;
if (typeof p === 'string') return p; // 'player' | 'primitive:NN'
if (typeof p === 'object') {
if (typeof p.ref === 'string') return p.ref; // Instance-proxy
if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) {
return { x: p.x, y: p.y, z: p.z }; // чистая точка
}
try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {}
}
return p;
}
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт // могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). // часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
function _guiHandlerKeys(id, localId) { function _guiHandlerKeys(id, localId) {
@ -1910,6 +1928,15 @@ const game = {
setVisible(visible) { setVisible(visible) {
_send('hud.setVisible', { visible: !!visible }); _send('hud.setVisible', { visible: !!visible });
}, },
/** Скрыть/показать только хотбар (5 слотов инвентаря снизу).
* Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */
setHotbarVisible(visible) {
_send('hud.setHotbarVisible', { visible: !!visible });
},
/** Скрыть/показать только HP-индикатор (полоска жизней). */
setHpVisible(visible) {
_send('hud.setHpVisible', { visible: !!visible });
},
}, },
/** /**
* Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода).
@ -2400,11 +2427,22 @@ const game = {
opts = opts || {}; opts = opts || {};
_fxRefSeq++; _fxRefSeq++;
const localRef = 'fx:_local_' + _fxRefSeq; const localRef = 'fx:_local_' + _fxRefSeq;
// Задача 08: расширенные опции (текстура/curved/градиент/billboard).
_send('fx.create', { _send('fx.create', {
kind: 'beam', localRef, kind: 'beam', localRef,
from: opts.from, to: opts.to, from: _normFxPoint(opts.from), to: _normFxPoint(opts.to),
color: typeof opts.color === 'string' ? opts.color : undefined, color: typeof opts.color === 'string' ? opts.color : undefined,
width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined,
texture: opts.texture, customTextureUrl: opts.customTextureUrl,
textureMode: opts.textureMode, textureSpeed: opts.textureSpeed,
textureScale: opts.textureScale,
strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth,
colorSequence: opts.colorSequence,
transparencySequence: opts.transparencySequence,
widthSequence: opts.widthSequence,
faceMode: opts.faceMode, segments: opts.segments,
curved: opts.curved, curveHeight: opts.curveHeight,
attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth,
}); });
return { return {
get ref() { return localRef; }, get ref() { return localRef; },
@ -2416,6 +2454,45 @@ const game = {
setEndpoints(from, to) { setEndpoints(from, to) {
_send('fx.beamEndpoints', { ref: localRef, from, to }); _send('fx.beamEndpoints', { ref: localRef, from, to });
}, },
/** Изменить любые опции луча на лету. */
update(o) {
_send('fx.beamUpdate', { ref: localRef, opts: o || {} });
},
hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
remove() { _send('fx.remove', { ref: localRef }); },
};
},
/**
* Стрелка-указатель «иди сюда» (бегущие шевроны + парящий quest-marker
* над целью). Задача 08.
* const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' });
* arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove();
* preset: 'guide'|'quest'|'danger'|'gift'|'custom'.
* from/to: 'player' | ref-объекта | {x,y,z}.
*/
pointer(opts) {
opts = opts || {};
_fxRefSeq++;
const localRef = 'fx:_local_' + _fxRefSeq;
_send('fx.createPointer', {
localRef,
from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'),
to: _normFxPoint(opts.to),
preset: opts.preset || 'guide',
color: opts.color, texture: opts.texture,
customTextureUrl: opts.customTextureUrl,
textureSpeed: opts.textureSpeed, width: opts.width,
strokeColor: opts.strokeColor, colorSequence: opts.colorSequence,
curved: opts.curved, curveHeight: opts.curveHeight,
faceMode: opts.faceMode, attachOffset: opts.attachOffset,
});
return {
get ref() { return localRef; },
setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); },
update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); },
hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); },
show() { _send('fx.beamVisible', { ref: localRef, visible: true }); },
remove() { _send('fx.remove', { ref: localRef }); }, remove() { _send('fx.remove', { ref: localRef }); },
}; };
}, },