feat: ���� 3D-�������-��������� � ����� + dev JWT-������ #9
5
.env.production
Normal file
5
.env.production
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
VITE_API_BASE=https://minecraftia-school.ru
|
||||||
|
VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game
|
||||||
|
VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game
|
||||||
|
VITE_RUBLOX_HOME=https://rublox.pro/app
|
||||||
|
VITE_STANDALONE=false
|
||||||
104
API_USAGE.md
Normal file
104
API_USAGE.md
Normal 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
|
||||||
@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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;
|
||||||
if (!s) return;
|
s?.player?.setUiCursorMode?.(true);
|
||||||
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) подсовывает встроенный
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -5343,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);
|
||||||
});
|
});
|
||||||
@ -5534,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);
|
||||||
@ -5924,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.
|
||||||
@ -7340,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;
|
||||||
|
|||||||
@ -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 идёт ПО длине (from→to). Шеврон рисуем «>» указывающим в
|
||||||
* 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): две линии вершин вдоль направления from→to,
|
||||||
|
* смещённые перпендикулярно (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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
109
src/engine/README.md
Normal file
109
src/engine/README.md
Normal 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
|
||||||
@ -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) {
|
||||||
@ -2409,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; },
|
||||||
@ -2425,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 }); },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user