diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 917275e..7de0265 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -150,9 +150,9 @@ jobs: run: | ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \ min@85.175.7.40 \ - "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" + "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true" - name: Verify S2 (обязательный) run: | ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \ min@192.168.0.124 \ - "ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/" + "ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)" diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index b5fb6c2..e2b2b5c 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -596,8 +596,10 @@ const KubikonPlayer = () => { try { scene.setPlayerModelType?.(mySkin); } catch (e) {} setLoading(false); - // Засчитываем плей - Kubikon3DApi.incrementPlay(projectId).catch(() => {}); + // Засчитываем плей. Передаём user_id (если залогинен) — + // это активирует self-cooldown (автор не накручивает себе) + // и user-cooldown на бэке (см. /play в Kubikon3D.py). + Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {}); // Запускаем игру сразу setTimeout(() => { scene.enterPlayMode?.(); diff --git a/src/api/Kubikon3DService.js b/src/api/Kubikon3DService.js index ae55610..5a22af8 100644 --- a/src/api/Kubikon3DService.js +++ b/src/api/Kubikon3DService.js @@ -1,487 +1,488 @@ -/** - * API-сервис для Рублокс 3D (отдельный от 2D-Кубоши). - * Бэкенд: storys-микросервис, префикс /kubikon3d/... - */ -import axios from 'axios'; -import { STORYS_addres } from './API'; - -const api = axios.create({ - baseURL: STORYS_addres, - timeout: 30000, - // Поднимаем лимит размера body — без этого axios отказывается отправлять - // payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ - // для 250м карты, но запас не помешает. - maxContentLength: 100 * 1024 * 1024, // 100 МБ - maxBodyLength: 100 * 1024 * 1024, -}); - -// Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть -// user-микросервис и узнать имя пользователя (resolve_my_username). -// Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя. -// -// В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization' -// как в Майнкрафтии), потому что плеер живёт на отдельном поддомене -// player.rublox.pro и его localStorage изолирован. Ключ перенумерован -// в Этапе 2 портирования плеера. -api.interceptors.request.use((config) => { - try { - const token = localStorage.getItem('player_jwt'); - if (token) { - config.headers = config.headers || {}; - config.headers.Authorization = token; - } - } catch (e) { /* ignore */ } - return config; -}); - -// ============ ПРОЕКТЫ ============ - -// Save-операции с увеличенным таймаутом (120с) — для больших карт. -// Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м -// карте, потому что upload 5+МБ JSON по медленной сети занимает больше. -const SAVE_TIMEOUT = 120000; - -export const createProject = (userId, data) => - api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT }); - -/** - * Загрузить проект по id. - * - * Бэкенд проверяет права доступа по правилам: - * - published — открыто всем (можно вызвать без userId) - * - draft / review / blocked — только автору и админу - * - * Поэтому если открываем чужой/свой черновик в редакторе — обязательно - * передаём userId, иначе бэк отдаст 403. - */ -export const getProject = (id, userId = null) => { - const params = {}; - if (userId != null) params.user_id = userId; - return api.get(`/kubikon3d/projects/${id}`, { params }); -}; - -/** - * Загрузить проект с retry — на случай зависшего/медленного запроса. - * - * ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный - * пул соединений, сетевой лаг) — и страница "Загрузка проекта… 0%" - * замирает на 30с (таймаут axios) или до 60с (safety-timer редактора). - * Приходилось перезагружать вручную по 5 раз. - * - * РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая - * попытка отменяется и повторяется сама, без ручной перезагрузки. - * Сетевые/таймаут-ошибки → retry; 4xx (403/404) → сразу пробрасываем - * (повтор не поможет). - * - * @param {number} id — id проекта - * @param {number|null} userId - * @param {number} attempts — сколько попыток (по умолчанию 3) - * @param {number} perTryTimeout — таймаут одной попытки в мс (по умолчанию 12000) - */ -export const getProjectWithRetry = async ( - id, userId = null, attempts = 3, perTryTimeout = 12000, -) => { - const params = {}; - if (userId != null) params.user_id = userId; - let lastErr = null; - for (let i = 0; i < attempts; i++) { - try { - return await api.get(`/kubikon3d/projects/${id}`, { - params, - timeout: perTryTimeout, - }); - } catch (err) { - lastErr = err; - const status = err.response?.status; - // 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу. - if (status && status >= 400 && status < 500 - && status !== 408 && status !== 429) { - throw err; - } - // Сеть/таймаут/5xx — пробуем ещё раз. - console.warn( - `[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed` - + ` (${err.code || status || 'network'}), retrying...`, - ); - } - } - throw lastErr; -}; - -export const updateProject = (id, data) => - api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT }); - -export const deleteProject = (id, userId) => - api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } }); - -export const getMyProjects = (userId) => - api.get('/kubikon3d/my-projects', { params: { user_id: userId } }); - -/** - * Лента игр Рублокса (умная лента — RUBLOX_SMART_FEED_PLAN.md). - * - * Второй аргумент — вкладка ленты: - * recommended — ранжирование по hot_score (умная лента); - * new — самые свежие; - * popular — по числу запусков; - * top_week — топ за неделю. - * Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя) — - * шлём под обоими именами, чтобы не зависеть от версии бэкенда. - */ -export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) => - api.get('/kubikon3d/feed', { - params: { - page, tab, sort: tab, - ...(maxAge != null ? { max_age: maxAge } : {}), - ...(minRating != null ? { min_rating: minRating } : {}), - ...(opts.rank ? { rank: opts.rank } : {}), - ...(opts.multiplayer != null - ? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}), - ...(opts.genre ? { genre: opts.genre } : {}), - ...(opts.per_page ? { per_page: opts.per_page } : {}), - }, - }); - -export const searchProjects = (q, maxAge = null) => - api.get('/kubikon3d/search', { - params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) }, - }); - -// ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============ - -/** - * Опубликовать проект (умная лента — RUBLOX_SMART_FEED_PLAN.md). - * Премодерации нет: чистая игра сразу в ленте, подозрительная → review. - * payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? } - * Ответ: { project, review: bool, too_empty: bool } - */ -export const publishProject = (id, payload) => - api.post(`/kubikon3d/projects/${id}/publish`, payload); - -export const unpublishProject = (id, userId) => - api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId }); - -/** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */ -export const getModerationQueue = () => - api.get('/kubikon3d/admin/moderation-queue'); - -/** - * Решение админа по игре из очереди проверки. - * payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? } - */ -export const moderateProject = (id, payload) => - api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload); - -/** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */ -export const blockProject = (id, payload) => - api.post(`/kubikon3d/admin/projects/${id}/block`, payload); - -/** Разблокировать игру → published. payload: { admin_user_id } */ -export const unblockProject = (id, payload) => - api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload); - -/** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */ -export const restoreFeed = (id, payload) => - api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload); - -export const getModerationHistory = (id) => - api.get(`/kubikon3d/projects/${id}/moderation-history`); - -// ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============ - -/** Загрузить проект для плеера. Передаём user_id для проверки доступа. */ -export const getProjectForPlay = (id, userId = null, isAdmin = false) => - api.get(`/kubikon3d/projects/${id}`, { - params: { - ...(userId ? { user_id: userId } : {}), - ...(isAdmin ? { is_admin: 'true' } : {}), - }, - }); - -export const incrementPlay = (id) => - api.post(`/kubikon3d/projects/${id}/play`); - -/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, - * голос другого типа — переключает. */ -export const toggleLike = (id, userId, kind = 'like') => - api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind }); - -export const getLikeStatus = (id, userId) => - api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } }); - -/** payload: { reporter_user_id, target_type, target_id, category, text } */ -export const createReport = (payload) => - api.post('/kubikon3d/reports', payload); - -/** Публичные игры автора. */ -export const getUserGames = (userId, maxAge = null) => - api.get(`/kubikon3d/users/${userId}/games`, { - params: maxAge != null ? { max_age: maxAge } : {}, - }); - -// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============ - -/** Топ-N рекордов прохождения проекта. По умолчанию 5. */ -export const getLeaderboard = (projectId, limit = 5) => - api.get(`/kubikon3d/projects/${projectId}/leaderboard`, { - params: { limit }, - }); - -/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */ -export const submitLeaderboard = (projectId, userId, timeMs) => - api.post(`/kubikon3d/projects/${projectId}/leaderboard`, { - user_id: userId, - time_ms: timeMs, - }); - -// ============ ИЗБРАННОЕ ============ -export const toggleFavorite = (projectId, userId) => - api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId }); -export const getFavoriteStatus = (projectId, userId) => - api.get(`/kubikon3d/projects/${projectId}/favorite-status`, - { params: { user_id: userId } }); -export const getMyFavorites = (userId) => - api.get(`/kubikon3d/users/${userId}/favorites`); - -// ============ ИСТОРИЯ ============ -export const getPlayHistory = (userId, limit = 8) => - api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } }); - -// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============ -export const getTrending = (limit = 8) => - api.get('/kubikon3d/trending', { params: { limit } }); -export const getTopAuthors = (limit = 10) => - api.get('/kubikon3d/top-authors', { params: { limit } }); -export const getActivity = (limit = 10) => - api.get('/kubikon3d/activity', { params: { limit } }); -export const getCollections = () => - api.get('/kubikon3d/collections'); -export const getEvents = () => - api.get('/kubikon3d/events'); - -// ============ PERF LOGS ============ -export const submitPerfLog = (sample) => - api.post('/kubikon3d/perf-log', sample); - -// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============ - -/** - * Создать баг-репорт. Использует multipart/form-data, потому что может нести файл. - * fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File } - */ -export const createBugReport = (fields) => { - const fd = new FormData(); - Object.entries(fields).forEach(([k, v]) => { - if (v == null || v === '') return; - fd.append(k, v); - }); - return api.post('/kubikon3d/bug-reports', fd, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); -}; - -export const getAdminBugReports = (status = 'open', limit = 100) => - api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } }); - -export const updateAdminBugReport = (id, payload) => - api.post(`/kubikon3d/admin/bug-reports/${id}`, payload); - -// ============ HEARTBEAT / ОНЛАЙН ============ - -export const playHeartbeat = (sessionId, projectId, userId = null) => - api.post('/kubikon3d/play/heartbeat', { - session_id: sessionId, - project_id: projectId, - user_id: userId, - }); - -export const getOnline = () => - api.get('/kubikon3d/admin/online'); - -// ============ DASHBOARD / СТАТИСТИКА ============ - -export const getDashboard = () => - api.get('/kubikon3d/admin/dashboard'); - -export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) => - api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } }); - -export const getAdminAuthors = (limit = 100) => - api.get('/kubikon3d/admin/authors', { params: { limit } }); - -// ============ ЖАЛОБЫ (АДМИНКА) ============ - -export const getAdminReports = (status = 'open', limit = 200) => - api.get('/kubikon3d/admin/reports', { params: { status, limit } }); - -export const resolveAdminReport = (id, status, adminUserId) => - api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId }); - -// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============ - -/** Публичный — узнать активный бан публикаций пользователя. */ -export const getPublishBanStatus = (userId) => - api.get(`/kubikon3d/users/${userId}/publish-ban-status`); -export const getPublishBanHistory = (userId) => - api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`); - -// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============ - -export const getProjectComments = (projectId) => - api.get(`/kubikon3d/projects/${projectId}/comments`); - -/** payload: { user_id, username, text } */ -export const createProjectComment = (projectId, payload) => - api.post(`/kubikon3d/projects/${projectId}/comments`, payload); - -export const deleteProjectComment = (commentId, userId) => - api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } }); - -export const editProjectComment = (commentId, userId, text) => - api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text }); - -// Админ -export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) => - api.get('/kubikon3d/admin/comments', { - params: { filter, ...(projectId ? { project: projectId } : {}), limit }, - }); - -// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============ - -/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */ -export const getChat = (projectId, since = null, limit = 50) => - api.get(`/kubikon3d/projects/${projectId}/chat`, { - params: { ...(since ? { since } : {}), limit }, - }); - -/** payload: { user_id, username, text } */ -export const postChatMessage = (projectId, payload) => - api.post(`/kubikon3d/projects/${projectId}/chat`, payload); - -/** Узнать активный мьют чата для пользователя. */ -export const getChatMuteStatus = (userId) => - api.get(`/kubikon3d/users/${userId}/chat-mute-status`); - -// Админ -export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) => - api.get('/kubikon3d/admin/chat/messages', { - params: { filter, ...(projectId ? { project: projectId } : {}), limit }, - }); - -export const getAdminChatBans = (filter = 'active', limit = 200) => - api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } }); - -// ============================================================================ -// Пользовательские модели (Этап 1+ редактора моделей) -// ============================================================================ -// Эндпоинты для воксельных и гладких моделей, созданных пользователями. -// Отображаются в Toolbox в разделах "Мои модели" и "Сообщество". -// См. KUBIKON_MODEL_EDITOR_PLAN.md. - -/** Создать модель. - * payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON, - * description?, thumbnail_b64? } - * Возвращает serialize_full (с model_data). - */ -export const createUserModel = (userId, payload) => - api.post('/kubikon3d/models', { user_id: userId, ...payload }, - { timeout: SAVE_TIMEOUT }); - -/** Загрузить модель по id. userId — для проверки доступа к приватной. */ -export const getUserModel = (id, userId = null) => { - const params = {}; - if (userId != null) params.user_id = userId; - return api.get(`/kubikon3d/models/${id}`, { params }); -}; - -/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */ -export const updateUserModel = (id, userId, payload) => - api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload }, - { timeout: SAVE_TIMEOUT }); - -export const deleteUserModel = (id, userId) => - api.delete(`/kubikon3d/models/${id}`, { - data: { user_id: userId }, - params: { user_id: userId }, - }); - -/** Мои модели (для раздела "Мои" в Toolbox). */ -export const getMyUserModels = (userId, opts = {}) => - api.get('/kubikon3d/models/mine', { - params: { - user_id: userId, - ...(opts.kind ? { kind: opts.kind } : {}), - ...(opts.limit ? { limit: opts.limit } : {}), - ...(opts.offset ? { offset: opts.offset } : {}), - }, - }); - -/** Публичные модели (для раздела "Сообщество" в Toolbox). - * opts: { q, kind, limit, offset, userId } - * userId — чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */ -export const getPublicUserModels = (opts = {}) => - api.get('/kubikon3d/models/public', { - params: { - ...(opts.q ? { q: opts.q } : {}), - ...(opts.kind ? { kind: opts.kind } : {}), - ...(opts.limit ? { limit: opts.limit } : {}), - ...(opts.offset ? { offset: opts.offset } : {}), - ...(opts.userId != null ? { user_id: opts.userId } : {}), - }, - }); - -export const publishUserModel = (id, userId) => - api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId }); - -export const unpublishUserModel = (id, userId) => - api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId }); - -/** Инкремент uses_count — вызывать когда модель ставят в проект. */ -export const incrementModelUses = (id) => - api.post(`/kubikon3d/models/${id}/use`); - -/** Поставить/снять лайк пользовательской модели (toggle). - * Возвращает { liked, likes_count }. */ -export const likeUserModel = (id, userId) => - api.post(`/kubikon3d/models/${id}/like`, { user_id: userId }); - -// ============ СКИНЫ ИГРОКА (R15) ============ - -/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */ -export const getOwnedSkins = (userId) => - api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } }); - -/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }. - * Если записи нет — бэк отдаёт дефолт skin_bacon-hair. */ -export const getEquippedSkin = (userId) => - api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } }); - -/** Установить выбранный скин игрока. Бэк проверяет владение скином. - * Возвращает { ok, skin_folder } или ошибку. */ -export const setEquippedSkin = (userId, skinFolder) => - api.post('/kubikon3d/rublox/equipped-skin', { - user_id: userId, skin_folder: skinFolder, - }); - -/** Дизайнерский эндпоинт — получить один скин по id (видит и draft/testing). - * Используется в preview-режиме `/_preview-skin/:itemId`. - * Требует JWT с ролью designer или owner. Возвращает item.serialize. */ -export const getDesignerSkin = (itemId) => - api.get(`/designer/skins/${itemId}`); - -/** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md). - * Возвращает { slots: {hat_id, tool_id, ...}, items: {: serialize, ...} }. - * В items уже есть attachment-поля — плеер сразу делает equipAccessory. */ -export const getRubloxOutfit = (userId) => - api.get('/rublox/outfit', { params: { user_id: userId } }); - -/** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md). - * Видит draft/testing — требует JWT с ролью designer/owner. - * Используется в preview-режиме /_preview-model/:id. */ -export const getDesignerModel = (modelId) => - api.get(`/designer/models/${modelId}`); - -/** Дизайнерский аватар (2026-05-27). Видит draft/testing. - * Используется в preview-режиме /_preview-avatar/:id. */ -export const getDesignerAvatar = (avatarId) => - api.get(`/designer/avatars/${avatarId}`); +/** + * API-сервис для Рублокс 3D (отдельный от 2D-Кубоши). + * Бэкенд: storys-микросервис, префикс /kubikon3d/... + */ +import axios from 'axios'; +import { STORYS_addres } from './API'; + +const api = axios.create({ + baseURL: STORYS_addres, + timeout: 30000, + // Поднимаем лимит размера body — без этого axios отказывается отправлять + // payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ + // для 250м карты, но запас не помешает. + maxContentLength: 100 * 1024 * 1024, // 100 МБ + maxBodyLength: 100 * 1024 * 1024, +}); + +// Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть +// user-микросервис и узнать имя пользователя (resolve_my_username). +// Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя. +// +// В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization' +// как в Майнкрафтии), потому что плеер живёт на отдельном поддомене +// player.rublox.pro и его localStorage изолирован. Ключ перенумерован +// в Этапе 2 портирования плеера. +api.interceptors.request.use((config) => { + try { + const token = localStorage.getItem('player_jwt'); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = token; + } + } catch (e) { /* ignore */ } + return config; +}); + +// ============ ПРОЕКТЫ ============ + +// Save-операции с увеличенным таймаутом (120с) — для больших карт. +// Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м +// карте, потому что upload 5+МБ JSON по медленной сети занимает больше. +const SAVE_TIMEOUT = 120000; + +export const createProject = (userId, data) => + api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT }); + +/** + * Загрузить проект по id. + * + * Бэкенд проверяет права доступа по правилам: + * - published — открыто всем (можно вызвать без userId) + * - draft / review / blocked — только автору и админу + * + * Поэтому если открываем чужой/свой черновик в редакторе — обязательно + * передаём userId, иначе бэк отдаст 403. + */ +export const getProject = (id, userId = null) => { + const params = {}; + if (userId != null) params.user_id = userId; + return api.get(`/kubikon3d/projects/${id}`, { params }); +}; + +/** + * Загрузить проект с retry — на случай зависшего/медленного запроса. + * + * ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный + * пул соединений, сетевой лаг) — и страница "Загрузка проекта… 0%" + * замирает на 30с (таймаут axios) или до 60с (safety-timer редактора). + * Приходилось перезагружать вручную по 5 раз. + * + * РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая + * попытка отменяется и повторяется сама, без ручной перезагрузки. + * Сетевые/таймаут-ошибки → retry; 4xx (403/404) → сразу пробрасываем + * (повтор не поможет). + * + * @param {number} id — id проекта + * @param {number|null} userId + * @param {number} attempts — сколько попыток (по умолчанию 3) + * @param {number} perTryTimeout — таймаут одной попытки в мс (по умолчанию 12000) + */ +export const getProjectWithRetry = async ( + id, userId = null, attempts = 3, perTryTimeout = 12000, +) => { + const params = {}; + if (userId != null) params.user_id = userId; + let lastErr = null; + for (let i = 0; i < attempts; i++) { + try { + return await api.get(`/kubikon3d/projects/${id}`, { + params, + timeout: perTryTimeout, + }); + } catch (err) { + lastErr = err; + const status = err.response?.status; + // 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу. + if (status && status >= 400 && status < 500 + && status !== 408 && status !== 429) { + throw err; + } + // Сеть/таймаут/5xx — пробуем ещё раз. + console.warn( + `[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed` + + ` (${err.code || status || 'network'}), retrying...`, + ); + } + } + throw lastErr; +}; + +export const updateProject = (id, data) => + api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT }); + +export const deleteProject = (id, userId) => + api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } }); + +export const getMyProjects = (userId) => + api.get('/kubikon3d/my-projects', { params: { user_id: userId } }); + +/** + * Лента игр Рублокса (умная лента — RUBLOX_SMART_FEED_PLAN.md). + * + * Второй аргумент — вкладка ленты: + * recommended — ранжирование по hot_score (умная лента); + * new — самые свежие; + * popular — по числу запусков; + * top_week — топ за неделю. + * Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя) — + * шлём под обоими именами, чтобы не зависеть от версии бэкенда. + */ +export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) => + api.get('/kubikon3d/feed', { + params: { + page, tab, sort: tab, + ...(maxAge != null ? { max_age: maxAge } : {}), + ...(minRating != null ? { min_rating: minRating } : {}), + ...(opts.rank ? { rank: opts.rank } : {}), + ...(opts.multiplayer != null + ? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}), + ...(opts.genre ? { genre: opts.genre } : {}), + ...(opts.per_page ? { per_page: opts.per_page } : {}), + }, + }); + +export const searchProjects = (q, maxAge = null) => + api.get('/kubikon3d/search', { + params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) }, + }); + +// ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============ + +/** + * Опубликовать проект (умная лента — RUBLOX_SMART_FEED_PLAN.md). + * Премодерации нет: чистая игра сразу в ленте, подозрительная → review. + * payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? } + * Ответ: { project, review: bool, too_empty: bool } + */ +export const publishProject = (id, payload) => + api.post(`/kubikon3d/projects/${id}/publish`, payload); + +export const unpublishProject = (id, userId) => + api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId }); + +/** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */ +export const getModerationQueue = () => + api.get('/kubikon3d/admin/moderation-queue'); + +/** + * Решение админа по игре из очереди проверки. + * payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? } + */ +export const moderateProject = (id, payload) => + api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload); + +/** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */ +export const blockProject = (id, payload) => + api.post(`/kubikon3d/admin/projects/${id}/block`, payload); + +/** Разблокировать игру → published. payload: { admin_user_id } */ +export const unblockProject = (id, payload) => + api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload); + +/** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */ +export const restoreFeed = (id, payload) => + api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload); + +export const getModerationHistory = (id) => + api.get(`/kubikon3d/projects/${id}/moderation-history`); + +// ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============ + +/** Загрузить проект для плеера. Передаём user_id для проверки доступа. */ +export const getProjectForPlay = (id, userId = null, isAdmin = false) => + api.get(`/kubikon3d/projects/${id}`, { + params: { + ...(userId ? { user_id: userId } : {}), + ...(isAdmin ? { is_admin: 'true' } : {}), + }, + }); + +export const incrementPlay = (id, userId) => + api.post(`/kubikon3d/projects/${id}/play`, + userId ? { user_id: userId } : {}); + +/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, + * голос другого типа — переключает. */ +export const toggleLike = (id, userId, kind = 'like') => + api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind }); + +export const getLikeStatus = (id, userId) => + api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } }); + +/** payload: { reporter_user_id, target_type, target_id, category, text } */ +export const createReport = (payload) => + api.post('/kubikon3d/reports', payload); + +/** Публичные игры автора. */ +export const getUserGames = (userId, maxAge = null) => + api.get(`/kubikon3d/users/${userId}/games`, { + params: maxAge != null ? { max_age: maxAge } : {}, + }); + +// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============ + +/** Топ-N рекордов прохождения проекта. По умолчанию 5. */ +export const getLeaderboard = (projectId, limit = 5) => + api.get(`/kubikon3d/projects/${projectId}/leaderboard`, { + params: { limit }, + }); + +/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */ +export const submitLeaderboard = (projectId, userId, timeMs) => + api.post(`/kubikon3d/projects/${projectId}/leaderboard`, { + user_id: userId, + time_ms: timeMs, + }); + +// ============ ИЗБРАННОЕ ============ +export const toggleFavorite = (projectId, userId) => + api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId }); +export const getFavoriteStatus = (projectId, userId) => + api.get(`/kubikon3d/projects/${projectId}/favorite-status`, + { params: { user_id: userId } }); +export const getMyFavorites = (userId) => + api.get(`/kubikon3d/users/${userId}/favorites`); + +// ============ ИСТОРИЯ ============ +export const getPlayHistory = (userId, limit = 8) => + api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } }); + +// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============ +export const getTrending = (limit = 8) => + api.get('/kubikon3d/trending', { params: { limit } }); +export const getTopAuthors = (limit = 10) => + api.get('/kubikon3d/top-authors', { params: { limit } }); +export const getActivity = (limit = 10) => + api.get('/kubikon3d/activity', { params: { limit } }); +export const getCollections = () => + api.get('/kubikon3d/collections'); +export const getEvents = () => + api.get('/kubikon3d/events'); + +// ============ PERF LOGS ============ +export const submitPerfLog = (sample) => + api.post('/kubikon3d/perf-log', sample); + +// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============ + +/** + * Создать баг-репорт. Использует multipart/form-data, потому что может нести файл. + * fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File } + */ +export const createBugReport = (fields) => { + const fd = new FormData(); + Object.entries(fields).forEach(([k, v]) => { + if (v == null || v === '') return; + fd.append(k, v); + }); + return api.post('/kubikon3d/bug-reports', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +}; + +export const getAdminBugReports = (status = 'open', limit = 100) => + api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } }); + +export const updateAdminBugReport = (id, payload) => + api.post(`/kubikon3d/admin/bug-reports/${id}`, payload); + +// ============ HEARTBEAT / ОНЛАЙН ============ + +export const playHeartbeat = (sessionId, projectId, userId = null) => + api.post('/kubikon3d/play/heartbeat', { + session_id: sessionId, + project_id: projectId, + user_id: userId, + }); + +export const getOnline = () => + api.get('/kubikon3d/admin/online'); + +// ============ DASHBOARD / СТАТИСТИКА ============ + +export const getDashboard = () => + api.get('/kubikon3d/admin/dashboard'); + +export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) => + api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } }); + +export const getAdminAuthors = (limit = 100) => + api.get('/kubikon3d/admin/authors', { params: { limit } }); + +// ============ ЖАЛОБЫ (АДМИНКА) ============ + +export const getAdminReports = (status = 'open', limit = 200) => + api.get('/kubikon3d/admin/reports', { params: { status, limit } }); + +export const resolveAdminReport = (id, status, adminUserId) => + api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId }); + +// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============ + +/** Публичный — узнать активный бан публикаций пользователя. */ +export const getPublishBanStatus = (userId) => + api.get(`/kubikon3d/users/${userId}/publish-ban-status`); +export const getPublishBanHistory = (userId) => + api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`); + +// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============ + +export const getProjectComments = (projectId) => + api.get(`/kubikon3d/projects/${projectId}/comments`); + +/** payload: { user_id, username, text } */ +export const createProjectComment = (projectId, payload) => + api.post(`/kubikon3d/projects/${projectId}/comments`, payload); + +export const deleteProjectComment = (commentId, userId) => + api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } }); + +export const editProjectComment = (commentId, userId, text) => + api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text }); + +// Админ +export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) => + api.get('/kubikon3d/admin/comments', { + params: { filter, ...(projectId ? { project: projectId } : {}), limit }, + }); + +// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============ + +/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */ +export const getChat = (projectId, since = null, limit = 50) => + api.get(`/kubikon3d/projects/${projectId}/chat`, { + params: { ...(since ? { since } : {}), limit }, + }); + +/** payload: { user_id, username, text } */ +export const postChatMessage = (projectId, payload) => + api.post(`/kubikon3d/projects/${projectId}/chat`, payload); + +/** Узнать активный мьют чата для пользователя. */ +export const getChatMuteStatus = (userId) => + api.get(`/kubikon3d/users/${userId}/chat-mute-status`); + +// Админ +export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) => + api.get('/kubikon3d/admin/chat/messages', { + params: { filter, ...(projectId ? { project: projectId } : {}), limit }, + }); + +export const getAdminChatBans = (filter = 'active', limit = 200) => + api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } }); + +// ============================================================================ +// Пользовательские модели (Этап 1+ редактора моделей) +// ============================================================================ +// Эндпоинты для воксельных и гладких моделей, созданных пользователями. +// Отображаются в Toolbox в разделах "Мои модели" и "Сообщество". +// См. KUBIKON_MODEL_EDITOR_PLAN.md. + +/** Создать модель. + * payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON, + * description?, thumbnail_b64? } + * Возвращает serialize_full (с model_data). + */ +export const createUserModel = (userId, payload) => + api.post('/kubikon3d/models', { user_id: userId, ...payload }, + { timeout: SAVE_TIMEOUT }); + +/** Загрузить модель по id. userId — для проверки доступа к приватной. */ +export const getUserModel = (id, userId = null) => { + const params = {}; + if (userId != null) params.user_id = userId; + return api.get(`/kubikon3d/models/${id}`, { params }); +}; + +/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */ +export const updateUserModel = (id, userId, payload) => + api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload }, + { timeout: SAVE_TIMEOUT }); + +export const deleteUserModel = (id, userId) => + api.delete(`/kubikon3d/models/${id}`, { + data: { user_id: userId }, + params: { user_id: userId }, + }); + +/** Мои модели (для раздела "Мои" в Toolbox). */ +export const getMyUserModels = (userId, opts = {}) => + api.get('/kubikon3d/models/mine', { + params: { + user_id: userId, + ...(opts.kind ? { kind: opts.kind } : {}), + ...(opts.limit ? { limit: opts.limit } : {}), + ...(opts.offset ? { offset: opts.offset } : {}), + }, + }); + +/** Публичные модели (для раздела "Сообщество" в Toolbox). + * opts: { q, kind, limit, offset, userId } + * userId — чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */ +export const getPublicUserModels = (opts = {}) => + api.get('/kubikon3d/models/public', { + params: { + ...(opts.q ? { q: opts.q } : {}), + ...(opts.kind ? { kind: opts.kind } : {}), + ...(opts.limit ? { limit: opts.limit } : {}), + ...(opts.offset ? { offset: opts.offset } : {}), + ...(opts.userId != null ? { user_id: opts.userId } : {}), + }, + }); + +export const publishUserModel = (id, userId) => + api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId }); + +export const unpublishUserModel = (id, userId) => + api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId }); + +/** Инкремент uses_count — вызывать когда модель ставят в проект. */ +export const incrementModelUses = (id) => + api.post(`/kubikon3d/models/${id}/use`); + +/** Поставить/снять лайк пользовательской модели (toggle). + * Возвращает { liked, likes_count }. */ +export const likeUserModel = (id, userId) => + api.post(`/kubikon3d/models/${id}/like`, { user_id: userId }); + +// ============ СКИНЫ ИГРОКА (R15) ============ + +/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */ +export const getOwnedSkins = (userId) => + api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } }); + +/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }. + * Если записи нет — бэк отдаёт дефолт skin_bacon-hair. */ +export const getEquippedSkin = (userId) => + api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } }); + +/** Установить выбранный скин игрока. Бэк проверяет владение скином. + * Возвращает { ok, skin_folder } или ошибку. */ +export const setEquippedSkin = (userId, skinFolder) => + api.post('/kubikon3d/rublox/equipped-skin', { + user_id: userId, skin_folder: skinFolder, + }); + +/** Дизайнерский эндпоинт — получить один скин по id (видит и draft/testing). + * Используется в preview-режиме `/_preview-skin/:itemId`. + * Требует JWT с ролью designer или owner. Возвращает item.serialize. */ +export const getDesignerSkin = (itemId) => + api.get(`/designer/skins/${itemId}`); + +/** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md). + * Возвращает { slots: {hat_id, tool_id, ...}, items: {: serialize, ...} }. + * В items уже есть attachment-поля — плеер сразу делает equipAccessory. */ +export const getRubloxOutfit = (userId) => + api.get('/rublox/outfit', { params: { user_id: userId } }); + +/** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md). + * Видит draft/testing — требует JWT с ролью designer/owner. + * Используется в preview-режиме /_preview-model/:id. */ +export const getDesignerModel = (modelId) => + api.get(`/designer/models/${modelId}`); + +/** Дизайнерский аватар (2026-05-27). Видит draft/testing. + * Используется в preview-режиме /_preview-avatar/:id. */ +export const getDesignerAvatar = (avatarId) => + api.get(`/designer/avatars/${avatarId}`); diff --git a/src/editor-shared/Hotbar.jsx b/src/editor-shared/Hotbar.jsx index 3e1cfa2..1b313de 100644 --- a/src/editor-shared/Hotbar.jsx +++ b/src/editor-shared/Hotbar.jsx @@ -16,6 +16,13 @@ import Icon from './Icon'; function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { if (!visible) return null; + // ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни + // одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar + // из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен. + // Панель появится автоматически, как только в слот попадёт предмет. + const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null); + if (!hasAnyItem) return null; + const SLOT_COUNT = 5; const cells = []; for (let i = 0; i < SLOT_COUNT; i++) { diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 2c5acb6..d9df2bf 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -1,7706 +1,7778 @@ -/** - * BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией. - * - * Управление камерой (как в Roblox Studio): - * - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси) - * - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда) - * - ПКМ + Q/E : вниз/вверх по миру - * - ПКМ + Shift : ускоренный полёт (×2.5) - * - Колесо : zoom (приближение по оси взгляда) - * - Средняя кнопка drag : pan (сдвиг параллельно экрану) - * - F : фокус на (0,0,0) — будет на выбранный объект позже - * - * Используем UniversalCamera + ручной обработчик мыши/клавиш для точной - * имитации Roblox-controls (стандартные attachControl делают не то что нужно). - * - * Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже. - */ -import { - Engine, - Scene, - UniversalCamera, - Vector3, - Color3, - Color4, - HemisphericLight, - DirectionalLight, - ShadowGenerator, - CascadedShadowGenerator, - SSAORenderingPipeline, - MeshBuilder, - StandardMaterial, - DynamicTexture, - UtilityLayerRenderer, - TransformNode, - ParticleSystem, - Texture, - Ray, - Tools as BabylonTools, -} from '@babylonjs/core'; -import { BlockManager } from './BlockManager'; -import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; -// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. -// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy -// TerrainManager как shadow-копия — для замеров статистики чанков и -// готовности к Этапу 2 (greedy meshing). -import { VoxelWorld } from './voxel/VoxelWorld'; -import { VoxelRenderer } from './voxel/VoxelRenderer'; -import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator'; -// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава). -import { DecoManager } from './DecoManager'; -import { GRASS_MODELS_POOL } from './DecoModels'; -import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder'; -import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; -import { ModelManager } from './ModelManager'; -import { PrimitiveManager } from './PrimitiveManager'; -import { BillboardUiManager } from './BillboardUiManager'; -import { getPrimitiveType } from './PrimitiveTypes'; -import { FolderManager } from './FolderManager'; -import { GuiManager } from './GuiManager'; -import { ModalManager } from './ModalManager'; -import { InventoryManager } from './InventoryManager'; -import { WeaponSystem } from './WeaponSystem'; -import { ZombieManager } from './ZombieManager'; -import { NpcManager } from './NpcManager'; -import { ConstraintManager } from './ConstraintManager'; -import { BeamManager } from './BeamManager'; -import { ZombieSpawnerManager } from './ZombieSpawnerManager'; -import { DynamicsManager } from './DynamicsManager'; -import { Environment } from './Environment'; -import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; -import { GameAudioManager } from './GameAudioManager'; -import { AssetManager } from './AssetManager'; -import { SoundLibrary } from './SoundLibrary'; -import { SoundManager } from './SoundManager'; -import { GlbLibrary } from './GlbLibrary'; -import { GdLevelManager } from './GdLevelManager'; -import { GdSkybox } from './GdSkybox'; -import { GdGroundSkin } from './GdGroundSkin'; -import { GdSpikes } from './GdSpikes'; -import { GdStartArch } from './GdStartArch'; -import { GdPortalArch } from './GdPortalArch'; -import { GdDiamond } from './GdDiamond'; -import { GdPlayerModeSkin } from './GdPlayerModeSkin'; -import { GdFinish } from './GdFinish'; -import { GdForest } from './GdForest'; -import { GdPlayerCube } from './GdPlayerCube'; -import { GdPlayerTrail } from './GdPlayerTrail'; -import { GdPostFx } from './GdPostFx'; -import { PhysicsAABB } from './PhysicsAABB'; -import { PlayerController } from './PlayerController'; -import { SelectionManager } from './SelectionManager'; -import { GizmoController } from './GizmoController'; -import { HistoryManager } from './HistoryManager'; -import { GameRuntime } from './GameRuntime'; -import { attachConsoleHook, devlogReset } from './devlog'; -import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh'; -import { VoxelGrid } from './terrain/VoxelGrid'; -import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain'; -import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid'; -import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager'; - -export class BabylonScene { - /** - * @param {HTMLCanvasElement} canvas — DOM-элемент для рендера - */ - constructor(canvas) { - // DevLog: на localhost подключаем перехват console.* для записи в файл - // на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude - // возможность читать свежие логи без копипасты вручную. - try { - devlogReset(); - attachConsoleHook(); - } catch (e) {} - this.canvas = canvas; - this.engine = null; - this.scene = null; - this.camera = null; - - // Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы - // работало на русской раскладке: KeyW не зависит от языка ввода. - this._codes = new Set(); - this._shiftDown = false; - this._isRotating = false; // ПКМ зажата → крутим камеру - this._isPanning = false; // СКМ зажата → pan - this._lastMouseX = 0; - this._lastMouseY = 0; - - // Параметры - this.MOVE_SPEED = 12; // юнитов/секунду при WASD - this.SHIFT_MULTIPLIER = 2.5; - this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель - this.ZOOM_SPEED = 1.5; - this.PAN_SENSITIVITY = 0.025; - - // Состояние редактора блоков - this.blockManager = null; - this.modelManager = null; - this.primitiveManager = null; - this.folderManager = null; - this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image) - this.inventory = null; // инвентарь игрока (9 слотов hot-bar) - this.weapons = null; // система оружия (создаётся при enterPlayMode) - this.zombieManager = null; // AI зомби (создаётся при enterPlayMode) - this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) - this.constraintManager = null; // связи объектов (Фаза 5, Constraints) - this.beamManager = null; // лучи и следы (Фаза 5.2) - this.spawnerManager = null; // спавнеры зомби - this.environment = null; - this.audioManager = null; - this.assetManager = null; // библиотека пользовательских картинок - this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5) - this.soundManager = null; // 3D-воспроизведение звука (Play-only) - this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8) - this.selection = null; // SelectionManager - // Тач-режим (мобилки/планшеты) — выставляется снаружи через - // setTouchMode() ДО enterPlayMode. Влияет на PlayerController. - this._touchMode = false; - this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase' - this._activeBlockType = 'grass'; - this._activeModelType = null; - this._activePrimitiveType = 'cube'; - this._ghostMesh = null; - this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°) - this._gizmo = null; - this._gizmoLayer = null; - this._gizmoDragging = false; // флаг что идёт drag гизмо - this._isDragPlacing = false; // флаг drag-постановки/удаления блоков - this._isTerrainBrushing = false; // флаг drag-кисти террейна - this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять - this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды - this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком - this._dragLockValue = 0; // значение по фиксированной оси - - // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) - this._spawnPoint = { x: 0, y: 5, z: 0 }; - // Модель персонажа для режима Play. - // Дефолт — R15-скин bacon-hair (классический Roblox-вид). - // 'skin_*' грузится из characters//body.glb (R15-скелет), - // 'character-*' — старые Kenney-модели. - this._playerModelType = 'skin_bacon-hair'; - // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. - // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). - this._worldHalf = 40; - // Видимость пола (можно «удалить» — пол исчезнет визуально и из физики) - this._floorEnabled = true; - // Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше) - this._jumpPowerMul = 1; - // Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен. - this._crosshair = 'none'; - - // Скрипты пользователя (массив { id, code, target? }). - // На этапе 2.1 — только один глобальный «scene script», без UI редактирования. - // Хранится в проекте через serialize/loadFromState. - this._scripts = []; - this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode - - // Режим Play - this.player = null; // PlayerController когда играем - this.physics = null; // PhysicsAABB - this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры - this._isPlaying = false; - - // Drag-detection: чтобы не ставить блок при rotate (mouseup без movement - // = клик; с movement = drag). - this._mouseDownTime = 0; - this._mouseDownX = 0; - this._mouseDownY = 0; - this._mouseDownButton = -1; - - // Слушатели — храним чтобы корректно отписаться - this._listeners = []; - this._resizeHandler = null; - } - - init() { - // На тач-устройствах сразу отключаем anti-aliasing — это даёт - // заметный буст FPS на мобилах. Anti-aliasing полезен только на - // больших мониторах с низким DPR. - const isTouchDevice = (typeof window !== 'undefined') && ( - 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0 - ); - const isSmallScreen = (typeof window !== 'undefined') - && window.innerWidth <= 1024; - const useAA = !(isTouchDevice && isSmallScreen); - // MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций - // Можно принудительно отключить через ?desktop=1 в URL (для отладки). - const forceDesktop = (typeof window !== 'undefined') - && new URLSearchParams(window.location.search).has('desktop'); - this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop; - this.engine = new Engine(this.canvas, useAA, { - preserveDrawingBuffer: true, - stencil: true, - // Parallel shader compile — критично для устранения фризов при - // повороте камеры. Когда новый material попадает во frustum, - // Babylon без этого синхронно компилит shader и блокирует UI. - // С параллельным compile рендер использует fallback shader и - // переключается на оптимизированный когда тот готов. - useHighPrecisionFloats: false, - powerPreference: 'high-performance', - }, true); - // MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН. - // Логи показали что узкое место — CPU (draw calls растут до 60k), - // а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS. - - // PERF-METRICS: счётчики для perf-overlay. Накопительно за окно - // сэмплинга 5сек, потом overlay читает и сбрасывает. - this._perfMetrics = { - render_ms_sum: 0, render_count: 0, - physics_ms_sum: 0, physics_count: 0, - script_ms_sum: 0, script_count: 0, - // Замер idle-времени между концом prev-render и началом next-render. - // Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток - // ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест). - idle_ms_sum: 0, idle_count: 0, - _lastRenderEnd: 0, - }; - - this.scene = new Scene(this.engine); - this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0); - // ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1), - // но без scene.ambientColor != 0 это умножается на 0 и боковые грани - // вокселей остаются чёрными (направленный свет не освещает их с - // sunset preset). (0.3,0.3,0.3) даёт мягкое всестороннее освещение, - // не пересвечивает существующие сцены. - this.scene.ambientColor = new Color3(0.3, 0.3, 0.3); - // Глобальный хендл для отладки из консоли: window.__BS — это инстанс - // BabylonScene; window.__SC — Babylon scene; window.__ENG — engine. - // window.__BJS — набор Babylon-классов для dev-инструментов - // (@babylonjs/core модульный, window.BABYLON не существует) — - // им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js. - if (typeof window !== 'undefined') { - window.__BS = this; - window.__SC = this.scene; - window.__ENG = this.engine; - window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools }; - } - // ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь. - // Когда true — ставим свойства материала (emissiveColor/disableLighting/ - // alpha) у новых мешей (трейсеры выстрелов, debris при смерти, - // муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти - // свойства не применяются. Эффект: трейсер/дебрис создаются, но - // НЕ ВИДНЫ. Включать только локально вокруг массовых операций - // (если когда-то появится нужда), сразу выключая обратно. - // Skip pointer-move picking — не делаем raycast от мыши на каждый - // mousemove. Игроку важны клик и hover-через-canvas, а не каждый move. - this.scene.skipPointerMovePicking = true; - // Параллельная компиляция шейдеров — фоновая компиляция новых - // материалов без блокировки рендера (если поддержано WebGL2). - if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) { - try { - // Babylon 6+ — это просто флаг capability, выставляется - // автоматически при поддержке. Логируем для отладки. - // eslint-disable-next-line no-console - console.log('[BabylonScene] parallel shader compile:', - !!this.engine.getCaps().parallelShaderCompile); - } catch (e) {} - } - - // Возвращаем detachControl — наши mousedown-listeners на canvas с - // capture=true должны работать без вмешательства Babylon-pointerHandler. - // Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене. - this.scene.detachControl(); - - this._createCamera(); - this._createLights(); - this._createGroundGrid(); - this._createGhostBlock(); - this._createSpawnMarker(); - this._setupInputControls(); - - // Менеджеры объектов - this.blockManager = new BlockManager(this.scene); - // При создании нового proto-меша блока — сразу регистрируем его - // как shadow caster (если генератор уже создан). - this.blockManager.setOnProtoCreated((proto) => { - this.addShadowCaster(proto); - }); - - // Менеджер декораций — Этап 6 voxel-движка. - // Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий. - this.decoManager = new DecoManager(this.scene); - this.decoManager.setOnChange(() => { - if (this._onSceneChange) this._onSceneChange(); - }); - - // Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями. - // Использует thin-instances per материал, как BlockManager. - this.terrainManager = new TerrainManager(this.scene); - // ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая - // карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки - // на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows - // оставлен — тени от других объектов (моделей) показываются. - // this.terrainManager.setOnProtoCreated((proto) => { - // this.addShadowCaster(proto); - // }); - this.terrainManager.setOnChange(() => { - // Пометить сцену как изменённую — автосохранение подхватит. - // Имя коллбэка — _onSceneChange (то же что у blockManager/ - // modelManager/primitiveManager). Раньше тут было _onChange — - // несуществующее поле, из-за чего террейн не сохранялся - // автоматически. Только ручная кнопка «Сохранить» дёргает - // serialize() напрямую и попадала в БД. - if (this._onSceneChange) this._onSceneChange(); - }); - - // === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре === - // Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами, - // но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld= - // false) — только структура данных для замера chunk-статистики и - // подготовки к Этапу 2. - // window.__voxelWorldStats() — выведет в консоль текущую статистику. - // window.__voxelWorldRender(true/false) — переключит рендер на новый - // (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и - // поверх старых — для визуальной валидации. - this.voxelWorld = new VoxelWorld(); - this.voxelWorld.setOnChange(() => { - // Авто-rebuild dirty чанков при изменении (только если рендер включён) - if (this._voxelRenderEnabled && this.voxelRenderer) { - this.voxelRenderer.rebuildDirty(); - } - }); - this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); - this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); - /** Включить/выключить рендер VoxelWorld. По умолчанию false — только - * loadFromArray в VoxelWorld для статистики, без отображения. */ - this._voxelRenderEnabled = false; - /** Этап 4 streaming: рендерить только чанки в радиусе от камеры. - * false по умолчанию — рендерим все чанки (для маленьких карт). - * Включается через window.__voxelWorldStreaming(true, 64). */ - this._voxelStreamingEnabled = false; - this._voxelStreamingRadius = 64; // метров - this._voxelStreamingLastUpdate = 0; - this._voxelStreamingInterval = 250; // мс между проверками - if (typeof window !== 'undefined') { - window.__voxelWorldStats = () => { - const s = this.voxelWorld.stats(); - console.log('[VoxelWorld stats]', s); - return s; - }; - // Диагностика FPS bottleneck'ов на больших картах. - // Запускать в консоли когда лагает: window.__voxelPerfReport() - // Debug-команды для диагностики FPS-проблем. - // Запускать в консоли — увидим что реально жрёт CPU/GPU. - window.__toggleShadows = (on) => { - this.setShadowQuality(on === false ? 'off' : 'soft'); - console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON'); - }; - window.__togglePostProcess = (on) => { - if (this.scene && this.scene.postProcessRenderPipelineManager) { - // Babylon не умеет тривиально выключать pipeline, поэтому - // просто отключаем все pipelines - const enabled = on !== false; - if (this._postProcessPipelines) { - for (const p of this._postProcessPipelines) { - try { p.setEnabled(enabled); } catch (e) {} - } - } - } - console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON'); - }; - window.__toggleSceneOptim = (on) => { - // Глобальные оптимизации Babylon - const scn = this.scene; - if (on !== false) { - scn.freezeActiveMeshes(); - scn.skipFrustumClipping = true; - scn.blockfreeActiveMeshesAndRenderingGroups = true; - console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON'); - } else { - scn.unfreezeActiveMeshes(); - scn.skipFrustumClipping = false; - scn.blockfreeActiveMeshesAndRenderingGroups = false; - console.log('[Debug] scene optim: OFF'); - } - }; - window.__voxelPerfReport = () => { - const tm = this.terrainManager; - if (!tm) return console.warn('no terrainManager'); - const scn = this.scene; - const eng = scn.getEngine(); - const totalMeshes = scn.meshes.length; - let activeMeshes = 0; - let activeRegionMeshes = 0; - let activeDecoMeshes = 0; - for (const m of scn.meshes) { - if (m.isEnabled() && m.material) activeMeshes++; - } - if (tm._regionMeshes) { - for (const m of tm._regionMeshes.values()) { - if (m.isEnabled()) activeRegionMeshes++; - } - } - if (this.decoManager?._chunkMeshes) { - for (const colorMap of this.decoManager._chunkMeshes.values()) { - for (const m of colorMap.values()) { - if (m.isEnabled()) activeDecoMeshes++; - } - } - } - // FPS: Babylon engine.getFps() даёт усреднённый, instantaneous - // (1000/getDeltaTime) скачет хаотично из-за GC. - const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime()); - const instFps = 1000 / eng.getDeltaTime(); - // Подсчёт активных треугольников и draw calls. - // Babylon хранит sceneInstrumentation, но он opt-in. - // Считаем вручную из активных мешей. - let activeTriangles = 0; - let activeVertices = 0; - let activeDrawCalls = 0; - if (this.scene && this.scene.meshes) { - for (const m of this.scene.meshes) { - if (!m.isEnabled() || !m.material) continue; - // Frustum cull skip - const idxCount = m.getTotalIndices?.() ?? 0; - if (idxCount === 0) continue; - // thin-instances умножают - const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1; - activeTriangles += (idxCount / 3) * instCount; - activeVertices += (m.getTotalVertices?.() ?? 0) * instCount; - // 1 draw call на меш (multimat = +submeshes) - const subMeshes = m.subMeshes ? m.subMeshes.length : 1; - activeDrawCalls += subMeshes; - } - } - // Frame time: целевые значения - // 60 FPS = 16.6мс/кадр - // 30 FPS = 33.3мс/кадр - // 23 FPS = 43.5мс/кадр ← наша проблема - const frameMs = eng.getDeltaTime(); - // Visibility — Chrome даёт throttle до 20 FPS если таб неактивен - const docHidden = typeof document !== 'undefined' && document.hidden; - const winFocused = typeof document !== 'undefined' && document.hasFocus?.(); - // PERF-DIAG: где теряется время? - // render_ms — сколько занимает scene.render() (GPU + babylon) - // idle_ms — промежуток между концом render и началом - // следующего кадра (если велик — GPU-bound - // ИЛИ браузер throttle; если мал, а frame_ms - // большой — узкое место в нашем JS до render). - const pm = this._perfMetrics; - const renderMsAvg = pm && pm.render_count - ? (pm.render_ms_sum / pm.render_count) : 0; - const idleMsAvg = pm && pm.idle_count - ? (pm.idle_ms_sum / pm.idle_count) : 0; - // Сбрасываем накопители — следующий отчёт за свежий период. - if (pm) { - pm.render_ms_sum = 0; pm.render_count = 0; - pm.idle_ms_sum = 0; pm.idle_count = 0; - } - console.log('[PerfReport]', { - fps_stable: stableFps.toFixed(1), - fps_instant: instFps.toFixed(1), - frame_ms: frameMs.toFixed(1), - render_ms: renderMsAvg.toFixed(1), - idle_ms: idleMsAvg.toFixed(1), - isPlaying: this._isPlaying, - triangles_K: (activeTriangles / 1000).toFixed(0) + 'K', - drawCalls: activeDrawCalls, - tab_hidden: docHidden, - win_focused: winFocused, - voxelCount: tm.voxels?.size ?? 0, - sceneMeshes: totalMeshes, - activeMeshes, - regionMeshes: tm._regionMeshes?.size ?? 0, - activeRegionMeshes, - decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0, - activeDecoMeshes, - streamingRadius: this._terrainStreamingRadius, - // Новый TerrainMesh (Roblox-style, voxel) - tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0, - tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0, - tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0, - // Roblox Smooth Terrain - rt_chunks: this._robloxTerrain?.chunks?.size ?? 0, - rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0, - rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0, - }); - }; - this._decoMeshCount = () => { - let n = 0; - if (!this.decoManager?._chunkMeshes) return 0; - for (const m of this.decoManager._chunkMeshes.values()) n += m.size; - return n; - }; - // === LEAK DETECTOR (dev-only) === - // Если sceneMeshes растёт без явной причины — каждый snapshot - // запоминаем имена мешей, на след. snapshot печатаем НОВЫЕ. - // Точно покажет утечку: какие меши накапливаются. - // Использовать: window.__leakSnap(); потом подождать 5 сек, - // снова window.__leakSnap() — выведет diff. - let _leakLastNames = null; - window.__leakSnap = () => { - const names = this.scene.meshes.map(m => m.name || ''); - if (_leakLastNames === null) { - _leakLastNames = new Map(); - for (const n of names) _leakLastNames.set(n, (_leakLastNames.get(n) || 0) + 1); - console.log('[LeakSnap] baseline:', names.length, 'мешей. Подожди 5+ сек и зови __leakSnap() снова.'); - return; - } - const cur = new Map(); - for (const n of names) cur.set(n, (cur.get(n) || 0) + 1); - const diff = {}; - let totalDiff = 0; - for (const [n, c] of cur) { - const prev = _leakLastNames.get(n) || 0; - if (c > prev) { diff[n] = `+${c - prev} (теперь ${c})`; totalDiff += c - prev; } - } - for (const [n, c] of _leakLastNames) { - if (!cur.has(n)) { diff[n] = `-${c} (удалён)`; totalDiff -= c; } - } - console.log('[LeakSnap] diff:', totalDiff > 0 ? `+${totalDiff}` : totalDiff, diff); - _leakLastNames = cur; - }; - // Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog. - // Активируется автоматически на localhost. На прод не работает. - window.__perfMonitorStart = (interval = 2000) => { - if (window.__perfMonitorTimer) { - clearInterval(window.__perfMonitorTimer); - } - window.__perfMonitorTimer = setInterval(() => { - try { window.__voxelPerfReport?.(); } catch (e) {} - }, interval); - console.log(`[PerfMonitor] started, interval=${interval}ms`); - }; - window.__perfMonitorStop = () => { - if (window.__perfMonitorTimer) { - clearInterval(window.__perfMonitorTimer); - window.__perfMonitorTimer = null; - console.log('[PerfMonitor] stopped'); - } - }; - // Автостарт мониторинга на localhost — Claude читает devlog.txt - if (typeof window !== 'undefined' - && (window.location.hostname === 'localhost' - || window.location.hostname === '127.0.0.1')) { - setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000); - } - // === Тест нового TerrainMesh (Roblox/Minecraft-style) === - // - // Создаёт VoxelGrid и заполняет его holmistym ландшафтом из - // sin-волн. Рендерится через Greedy Meshing. - // Использование в DevTools: - // __terrainTest(64) — небольшая карта 64×16×64м - // __terrainTest(150) — большая 150×24×150м - // __terrainTest(250) — целевая 250×32×250м - window.__terrainTest = (sizeMeters = 64) => { - if (!this._terrainMesh) { - this._terrainMesh = new TerrainMesh(this.scene); - } - const tm = this._terrainMesh; - // Удалим старый legacy terrain — он перекрывает картинку - try { - if (this.terrainManager) this.terrainManager.clear(); - if (this.decoManager) this.decoManager.clear(); - } catch (e) {} - tm.disposeAll(); - - const t0 = performance.now(); - const sx = sizeMeters, sz = sizeMeters; - const sy = 32; - const grid = new VoxelGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - // Заполняем heightmap-картой: y = базовый + sin(x)*cos(z) - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const fx = (x - sx / 2) / sx; - const fz = (z - sz / 2) / sz; - const h = Math.floor( - 6 + Math.sin(fx * Math.PI * 3) * 4 - + Math.cos(fz * Math.PI * 4) * 3 - + Math.sin((fx + fz) * Math.PI * 6) * 2, - ); - for (let y = 0; y < h && y < sy; y++) { - let mat; - if (y === h - 1) mat = 'grass'; - else if (y >= h - 3) mat = 'dirt'; - else mat = 'rock'; - grid.set(x, y, z, mat); - } - } - } - const tFill = performance.now() - t0; - const solid = grid.countSolid(); - console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`); - - tm.loadFromGrid(grid); - - // Сразу материализуем ВСЕ chunks (для теста, не lazy) - const t1 = performance.now(); - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 }); - const tBuild = performance.now() - t1; - const tris = tm.getActiveTriangles(); - console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`); - console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`); - }; - - // Удалить тестовый terrain mesh - window.__terrainTestClear = () => { - if (this._terrainMesh) { - this._terrainMesh.disposeAll(); - console.log('[TerrainTest] cleared'); - } - }; - - // ============================================================ - // Roblox-style Smooth Terrain test - // ============================================================ - // - // Использование в DevTools: - // __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м - // __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth) - // - // Создаёт holmistyy ландшафт через density-функцию и рендерит - // через Surface Nets. Проверка что архитектура работает. - window.__robloxTest = async (gridSize = 50, userParams = null) => { - if (!this._robloxTerrain) { - this._robloxTerrain = new RobloxTerrain(this.scene); - // Подключить к физике — иначе игрок проваливается в smooth terrain - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - } - const rt = this._robloxTerrain; - try { - if (this.terrainManager) this.terrainManager.clear(); - if (this.decoManager) this.decoManager.clear(); - if (this.voxelWorld) { - const layer = this.voxelWorld.getLayer?.('terrain'); - if (layer && layer.clear) layer.clear(); - } - this._terrainStreamingEnabled = false; - } catch (e) {} - rt.disposeAll(); - - const t0 = performance.now(); - const sx = gridSize, sz = gridSize; - const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор) - const grid = new RobloxDensityGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - - // === Используем тот же WorldGenerator что и voxel-генератор === - // 1 smooth-cell = 4м = 16 voxel-units. - // sampleHeight возвращает высоту в voxel-units (0.25м). - // sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}. - // - // userParams приходит из UI (TerrainGenPanel buildParams). - // Если null — берём дефолтные. - const params = userParams - ? JSON.parse(JSON.stringify(userParams)) - : JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS)); - console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`); - const gen = new WorldGenerator(params); - - // Маппинг материалов voxel-генератора → smooth (DensityGrid - // поддерживает только grass/rock/sand/snow). - // dirt → grass, остальные пропускаются. - const matMap = (m) => { - if (m === 'dirt') return 'grass'; - if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m; - return 'grass'; - }; - - // Шаг 1: heightmap + biome для каждой smooth-cell. - // x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16 - const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16 - const heightMap = new Float32Array(sx * sz); - const topMats = new Array(sx * sz); - const softMats = new Array(sx * sz); - const hardMats = new Array(sx * sz); - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2; - const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2; - const hVoxels = gen.sampleHeight(vx, vz); - const biome = gen.sampleBiome(vx, vz); - const hCells = hVoxels / CELL_VOXELS; - heightMap[z * sx + x] = hCells; - topMats[z * sx + x] = matMap(biome.topMaterial); - softMats[z * sx + x] = matMap(biome.softMaterial); - hardMats[z * sx + x] = matMap(biome.hardMaterial); - } - } - - // Шаг 2: density + материалы. - // Топ-слой: topMaterial биома. - // Средний (1..3 cells вглубь): softMaterial. - // Глубокий (>3 cells): hardMaterial. - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const h = heightMap[z * sx + x]; - const topMat = topMats[z * sx + x]; - const softMat = softMats[z * sx + x]; - const hardMat = hardMats[z * sx + x]; - const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h; - const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h; - const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h; - const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h; - const slope = Math.max( - Math.abs(h - h1), Math.abs(h - h2), - Math.abs(h - h3), Math.abs(h - h4), - ); - // На очень крутых обрывах (>3 cells = 12м перепад) — - // обнажение rock даже на травяных склонах. - const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow'; - - for (let y = 0; y < sy; y++) { - const delta = h - y; - let densityF; - if (delta > 2) densityF = 1; - else if (delta < -2) densityF = 0; - else densityF = (delta + 2) / 4; - const density = (densityF * 255) | 0; - if (density > 0) { - let mat; - if (useRockSlope) mat = 'rock'; - else if (delta < 1) mat = topMat; - else if (delta < 3) mat = softMat; - else mat = hardMat; - grid.set(x, y, z, density, mat); - } - } - } - } - const tFill = performance.now() - t0; - console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`); - - rt.loadFromGrid(grid); - - // Материализуем ВСЕ chunks сразу для теста. - const t1 = performance.now(); - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); - const tBuild = performance.now() - t1; - const stats = rt.getStats(); - console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`); - - // Мини-карта для свежесгенерированного гладкого ландшафта. - this._setupMinimapForRobloxTerrain(); - - // === Авто-спавн над поверхностью === - // Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0. - // Spawn = top_y + 2м над ней. - const CS = 4; // CELL_SIZE - const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell - const cellZ0 = 0 - grid.origin.z; - let topCellY = -1; - for (let cy = sy - 1; cy >= 0; cy--) { - if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; } - } - if (topCellY >= 0) { - const surfaceY = (grid.origin.y + topCellY + 1) * CS; - this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 }; - this._updateSpawnMarker?.(); - console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`); - } - - // Отключаем baseplate-пол — иначе он закрывает обзор и - // создаёт коллизии под smooth-ландшафтом. - try { this.setFloorEnabled(false); } catch (e) {} - - // === Декорации (цветы / трава / грибы) === - // Размещаем 3D-модели Kenney Nature Kit через thin-instances. - // Используем те же sampleHeight/sampleBiome из WorldGenerator - // что и для terrain — биомы определят какие декорации куда идут. - const decoOpts = userParams?.smoothDeco ?? { - flowersDensity: 0.025, - grassDensity: 0.10, - treesDensity: 0.4, - }; - // Сохраняем параметры для сериализации (при load воссоздадим) - this._smoothDecoParams = { - flowersDensity: decoOpts.flowersDensity, - grassDensity: decoOpts.grassDensity, - treesDensity: decoOpts.treesDensity ?? 0.4, - seed: params.seed || 1337, - bbox: { - minX: -(sx * CS) / 2, maxX: (sx * CS) / 2, - minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2, - }, - // Параметры WorldGenerator нужны для воссоздания biome-маппинга - genParams: params, - }; - if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) { - try { - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - const tDeco0 = performance.now(); - await this._smoothDecoManager.loadAll(); - const tDecoLoad = performance.now() - tDeco0; - // bbox в мировых координатах (метры) - const halfMeters = (sx * CS) / 2; - const bbox = { - minX: -halfMeters, maxX: halfMeters, - minZ: -halfMeters, maxZ: halfMeters, - }; - // Хелпер для surface raycast (использует физику) - const sampleSurfaceY = (x, z) => { - if (!this.physics?._sampleRobloxSurface) return null; - return this.physics._sampleRobloxSurface(x, z); - }; - const sampleBiomeId = (x, z) => { - // x,z в метрах → voxel-units (×4) - const vx = x * 4; - const vz = z * 4; - const biome = gen.sampleBiome(vx, vz); - return biome?.id; - }; - const tDeco1 = performance.now(); - const r = this._smoothDecoManager.placeDecorations({ - sampleSurfaceY, sampleBiomeId, bbox, - densityFlowers: decoOpts.flowersDensity, - densityGrass: decoOpts.grassDensity, - densityTrees: decoOpts.treesDensity ?? 0, - seed: params.seed || 1337, - }); - const tDecoPlace = performance.now() - tDeco1; - console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`); - // Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы. - if (this.physics?.setSmoothDecoTrees && r.treeColliders) { - this.physics.setSmoothDecoTrees(r.treeColliders); - } - } catch (e) { - console.error('[RobloxTest] decorations failed:', e); - } - } - - // Перемещаем редактор-камеру повыше чтобы видеть весь рельеф - if (this.camera && topCellY >= 0) { - const surfaceY = (grid.origin.y + topCellY + 1) * CS; - this.camera.position.x = sx * CS * 0.3; - this.camera.position.y = surfaceY + 30; - this.camera.position.z = sz * CS * 0.3; - this.camera.setTarget?.(new Vector3(0, surfaceY, 0)); - } - }; - - window.__robloxTestClear = () => this.clearRobloxTerrain(); - - // Этап 7a: процедурный генератор - // window.__voxelGenerate({size:160, params:{...}}) — генерирует - // террейн в bbox [-size..+size] и заменяет существующий terrain. - window.__voxelGenerate = async (opts = {}) => { - // ГЛОБАЛЬНЫЙ лок (на window, не на this!). - // Без него при HMR (hot module reload в dev) каждая копия - // BabylonScene имеет свой this._voxelGenerating, и команда - // в консоли вызывает все копии параллельно. - // window.__voxelGenLock виден ВСЕМ копиям сцены. - if (window.__voxelGenLock) { - console.warn('[VoxelGen] already running, ignoring duplicate call'); - return null; - } - window.__voxelGenLock = true; - // size — half-size в voxel-units (0.25м/voxel). - // Картa = size × 2 × 0.25м. - // size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию - // size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ - // Жёсткий лимит — 200 (карта 100×100м максимум). - // Для больших карт используйте Roblox-style smooth terrain. - try { - const MAX_SIZE = 200; - let size = opts.size ?? 160; - if (size > MAX_SIZE) { - console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`); - size = MAX_SIZE; - } - const params = opts.params ?? DEFAULT_GENERATOR_PARAMS; - // Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams) - window.__lastGenParams = params; - window.__lastGenSize = size; - console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`); - - // ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно - // (MultiCube для grass:top/side/bottom работает, текстуры - // настроены корректно). VoxelWorld остаётся как shadow-copy - // для RLE-сжатия в БД, но не для рендера. - // - // VoxelRenderer (новый) пока что выключен — он показывал - // серую кашу из-за проблем с MultiCube. - this._voxelRenderEnabled = false; - if (this.voxelRenderer) { - this.voxelRenderer.dispose(); - this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); - this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); - } - - // Progress callback — UI подхватывает через window.__voxelGenProgress. - const onProgress = (done, total, phase) => { - const pct = Math.min(100, Math.round((done / total) * 100)); - if (window.__voxelGenProgress) { - try { window.__voxelGenProgress(pct, phase); } catch (e) {} - } - }; - onProgress(0, 100, 'starting'); - - // Этап C оптимизации: генерация в Web Worker'е. - // Main thread не блокируется, UI отзывчив, progress-bar плавный. - const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker'); - const workerUrl = getTerrainGenWorkerUrl(); - const worker = new Worker(workerUrl); - - let voxels, decorations, treesPlaced, statsTimeMs; - try { - await new Promise((resolve, reject) => { - worker.onmessage = (e) => { - const m = e.data; - if (m.type === 'progress') { - onProgress(m.done, m.total, m.phase); - } else if (m.type === 'done') { - voxels = m.voxels; - decorations = m.decorations; - treesPlaced = m.treesPlaced; - statsTimeMs = m.timeMs; - resolve(); - } else if (m.type === 'error') { - reject(new Error('[Worker] ' + m.message)); - } - }; - worker.onerror = (err) => { - reject(new Error('[Worker] crash: ' + err.message)); - }; - worker.postMessage({ - type: 'generate', - params, - bbox: { x0: -size, z0: -size, x1: size, z1: size }, - }); - }); - } finally { - worker.terminate(); - URL.revokeObjectURL(workerUrl); - } - console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`); - onProgress(95, 100, 'render'); - await new Promise(r => setTimeout(r, 0)); - - // Заливаем в legacy TerrainManager (он отрендерит правильно). - // Очищаем TerrainMesh если был — на новых генерациях не нужен. - if (this._terrainMesh) { - try { this._terrainMesh.disposeAll(); } catch (e) {} - } - if (this.terrainManager) { - this.terrainManager.clear(); - if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') { - await this.terrainManager.loadFromArray(voxels); - } else { - this.terrainManager.loadFromArray(voxels); - } - console.log(`[VoxelGen] loaded into legacy TerrainManager`); - - // Также пишем в VoxelWorld для RLE-сжатия в БД - try { - const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25); - vwLayer.clear(); - vwLayer.loadFromArray(voxels); - } catch (e) { /* ignore */ } - - // АВТОВКЛЮЧЕНИЕ STREAMING для больших карт. - const regionCount = this.terrainManager.getRegionCount?.() ?? 0; - if (regionCount > 0) { - this._terrainStreamingEnabled = true; - // Адаптивный radius по количеству вокселей: чем больше - // карта, тем меньше radius (иначе слишком много рендерится). - // <300K voxels → 40м (норма для маленьких карт) - // 300K-1M → 36м - // 1M-2M → 32м - // >2M → 28м (очень большие) - const vc = voxels.length; - let radius = 40; - if (vc > 2_000_000) radius = 28; - else if (vc > 1_000_000) radius = 32; - else if (vc > 300_000) radius = 36; - this._terrainStreamingRadius = radius; - this._terrainStreamingLastUpdate = 0; - const cam = this.camera; - if (cam) { - const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius); - console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`); - } - } else { - this._terrainStreamingEnabled = false; - } - } - - // Этап 6: загружаем decorations (мини-воксельные цветы/грибы). - if (this.decoManager && decorations) { - this.decoManager.loadFromArray(decorations); - // Этап D: первый pass LOD streaming для деко. - // maxBuild=2 — деко достроятся плавно через updateStreaming. - if (this.camera && this.decoManager.updateStreaming) { - const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); - this.decoManager.updateStreaming( - this.camera.position.x, this.camera.position.z, decoRadius, - { maxBuild: 2 }, - ); - } - } - - onProgress(100, 100, 'done'); - return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs }; - } finally { - // Снять глобальный лок ОБЯЗАТЕЛЬНО. - window.__voxelGenLock = false; - } - }; - // Готовые пресеты для быстрого теста - window.__voxelPresets = { - default: DEFAULT_GENERATOR_PARAMS, - mountains: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 }, - }, - flat: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, - }, - islands: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 }, - }, - forest: { - ...DEFAULT_GENERATOR_PARAMS, - biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b => - b.id === 'plain' || b.id === 'forest' - ? { ...b, features: { ...b.features, trees: 1.5 } } - : b, - ), - }, - }; - - // Этап 4: streaming контроль - window.__voxelWorldStreaming = (enabled, radius = 64) => { - this._voxelStreamingEnabled = !!enabled; - this._voxelStreamingRadius = radius; - if (!this._voxelRenderEnabled) { - console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)'); - return; - } - if (!this._voxelStreamingEnabled) { - // Сброс: загрузить все чанки обратно - this.voxelRenderer.rebuildAll(); - console.log('[VoxelWorld] streaming OFF — все чанки видимы'); - return; - } - // Стартовый update вокруг камеры - const cam = this.camera; - const center = { x: cam.position.x, z: cam.position.z }; - const r = this.voxelRenderer.updateStreaming(center, radius); - console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); - }; - // Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64 - window.__voxelWorldBenchmarkRLE = () => { - const t0 = performance.now(); - const rleData = this.voxelWorld.serialize(); - const t1 = performance.now(); - const rleJson = JSON.stringify(rleData); - const t2 = performance.now(); - const rleBytes = new Blob([rleJson]).size; - - // Legacy формат для сравнения — массив {x,y,z,m} - const legacyVoxels = []; - const layer = this.voxelWorld.getLayer('terrain'); - if (layer) { - for (const ch of layer.chunks.values()) { - const ox = ch.voxelOriginX(); - const oy = ch.voxelOriginY(); - const oz = ch.voxelOriginZ(); - for (let i = 0; i < 32768; i++) { - const idx = ch.data[i]; - if (idx === 0) continue; - const m = layer.matIdxToId(idx); - const lx = i % 32; - const lz = ((i / 32) | 0) % 32; - const ly = (i / 1024) | 0; - legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m }); - } - } - } - const t3 = performance.now(); - const legacyJson = JSON.stringify(legacyVoxels); - const legacyBytes = new Blob([legacyJson]).size; - const t4 = performance.now(); - - const ratio = (legacyBytes / rleBytes).toFixed(1); - const sizes = this.voxelWorld.measureSize(); - console.log('[RLE Benchmark]'); - console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`); - console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`); - console.log(` Уменьшение: ${ratio}× меньше`); - console.log(' Подробно:', sizes); - return { legacy: legacyBytes, rle: rleBytes, ratio }; - }; - - // Чистый benchmark mesh-build без создания Babylon-meshей. Это - // показывает скорость алгоритма greedy в отрыве от GPU. - window.__voxelWorldBenchmark = async () => { - const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher'); - const { buildChunkGeometry } = await import('./voxel/ChunkMesher'); - const layer = this.voxelWorld.getLayer('terrain'); - if (!layer) { console.warn('no terrain layer'); return; } - const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); - - // Surface culling (Этап 1) - let totalFacesNonGreedy = 0; - const t1 = performance.now(); - for (const ch of layer.chunks.values()) { - const r = buildChunkGeometry(ch, layer, neighborMatIdx); - totalFacesNonGreedy += r.totalFaces; - } - const dt1 = performance.now() - t1; - - // Greedy (Этап 2) - let totalFacesGreedy = 0; - const t2 = performance.now(); - for (const ch of layer.chunks.values()) { - const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx); - totalFacesGreedy += r.totalFaces; - } - const dt2 = performance.now() - t2; - - const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1); - console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`); - console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`); - return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 }, - greedy: { quads: totalFacesGreedy, ms: dt2 }, - reduction: `${reduction}%` }; - }; - window.__voxelWorldRender = (enabled) => { - this._voxelRenderEnabled = !!enabled; - if (this._voxelRenderEnabled) { - // Прячем legacy TerrainManager mesh'и - for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { - proto.setEnabled(false); - } - // Если streaming ON — грузим только видимые чанки - // (rebuildAll потом бы их сразу half-выгрузил, лишняя работа). - if (this._voxelStreamingEnabled && this.camera) { - const cam = this.camera; - const r = this.voxelRenderer.updateStreaming( - { x: cam.position.x, z: cam.position.z }, - this._voxelStreamingRadius, - ); - console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); - } else { - this.voxelRenderer.rebuildAll(); - console.log('[VoxelWorld] render ENABLED, legacy hidden'); - } - } else { - // Показываем legacy обратно, скрываем новый - for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { - proto.setEnabled(true); - } - this.voxelRenderer.dispose(); - console.log('[VoxelWorld] render DISABLED, legacy restored'); - } - }; - } - // Состояние brush'а ландшафта, обновляется из TerrainPanel. - // tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten' - // material — id из TERRAIN_MATERIALS - // brushSize — радиус кисти в voxel'ах - // strength — 1..100 - // shape — 'sphere'|'cube'|'cylinder' - this._terrainBrush = { - tool: 'sculpt', - material: 'grass', - brushSize: 4, - strength: 50, - shape: 'sphere', - // terrainMode: 'voxel' (по умолчанию) | 'smooth'. - // В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes, - // в voxel — TerrainManager.voxels (как раньше). - terrainMode: 'voxel', - }; - // Полупрозрачный preview-меш под курсором (показывает где будет кисть) - this._terrainBrushPreview = null; - this.modelManager = new ModelManager(this.scene, this); - this.modelManager.setScene3D(this); - // Делаем ModelManager доступным через scene — MultiplayerSync.js - // подхватывает его для shared-кэша GLB-прототипов. - this.scene._kubikonModelManager = this.modelManager; - - // Этап 5 редактора моделей: менеджер пользовательских voxel-моделей. - // API подключается отдельно через setUserModelsApi (см. ниже), - // потому что Kubikon3DService импортируется через ES-modules. - this.userModelManager = new UserModelManager(this.scene); - // Глобальная функция для отладки: window.__kubikonDebugColliders() - // выводит в консоль все коллайдеры моделей и примитивов. - if (typeof window !== 'undefined') { - window.__kubikonDebugColliders = () => { - const out = []; - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - const a = data.localAABB; - if (a) { - const w = (a.maxX - a.minX).toFixed(2); - const h = (a.maxY - a.minY).toFixed(2); - const d = (a.maxZ - a.minZ).toFixed(2); - out.push({ - kind: 'model', id: data.modelTypeId, - pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], - size: [w, h, d], - canCollide: data.canCollide, - }); - } - } - } - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - out.push({ - kind: 'primitive', type: data.type, - pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], - size: [data.sx, data.sy, data.sz], - canCollide: data.canCollide, - }); - } - } - console.table(out); - return out; - }; - } - this.primitiveManager = new PrimitiveManager(this.scene); - // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц - // (createEmitterParticles живёт на обёртке). - this.primitiveManager.scene3d = this; - // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture - // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard - // (type='billboard') сразу применить текстуру с дефолтным пресетом. - this.billboardUiManager = new BillboardUiManager(this.scene); - this.primitiveManager.billboardUiManager = this.billboardUiManager; - this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); - this.guiManager = new GuiManager(); - this.modalManager = new ModalManager(); - this.modalManager.attachScene(this); - this.modalManager.attachGui(this.guiManager); - this.inventory = new InventoryManager(); - this.physics = new PhysicsAABB(this.blockManager); - // Сразу синхронизируем границу пола с текущим размером мира, - // иначе при дефолтных 40 игрок проваливается на больших картах - // ещё до первого setWorldSize(). - this.physics.floorHalf = this._worldHalf; - this.physics.setPrimitiveManager(this.primitiveManager); - this.physics.setModelManager(this.modelManager); - this.physics.setUserModelManager(this.userModelManager); - // Voxel-террейн тоже участвует в физике. У террейна свой размер - // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. - this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); - // Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике. - // Физика проверяет коллизии в обоих источниках (legacy terrainManager + - // voxelWorld), что позволяет постепенно мигрировать без поломки. - if (this.physics.setVoxelWorld && this.voxelWorld) { - this.physics.setVoxelWorld(this.voxelWorld); - } - this.dynamics = new DynamicsManager(this); - this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); - this.audioManager = new AudioManager(); - this.assetManager = new AssetManager(); - // PrimitiveManager должен уметь брать dataURL картинки по id ассета, - // чтобы применять пользовательскую текстуру на грани примитива. - this.primitiveManager.assetManager = this.assetManager; - // Библиотека пользовательских звуков (Фаза 5.5) — постоянная. - this.soundLibrary = new SoundLibrary(); - // Библиотека импортированных .glb-моделей (Фаза 5.8) — постоянная. - this.glbLibrary = new GlbLibrary(); - this.selection = new SelectionManager(this.scene, this.blockManager, this.modelManager); - this.selection.setPrimitiveManager(this.primitiveManager); - this.selection.setUserModelManager(this.userModelManager); - this.selection.setScene3D(this); - - // GizmoController — управляет 3 типами гизмо (move/rotate/scale). - // UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. - // Babylon автоматически активирует pointer-observable utility-сцены - // когда родительская scene control включён (мы убрали detachControl). - this._gizmoLayer = new UtilityLayerRenderer(this.scene); - - this._gizmo = new GizmoController(this._gizmoLayer, this.scene); - this._gizmo.setMode('select'); // по умолчанию — без манипулятора - this._gizmo.setSnap(1.0); // снэп для блоков - - // При окончании drag — синхронизируем - this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd()); - - // Привязка гизмо к выделенному - this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel)); - - // History (Undo/Redo). Сериализатор и восстановитель — методы этой сцены. - this.history = new HistoryManager( - () => { - try { return JSON.stringify(this.serialize()); } - catch (e) { return null; } - }, - async (state) => { - // При undo/redo — снимаем выделение (mesh может быть пересоздан) - this.selection?.clear(); - await this.loadFromState(state); - } - ); - // На любые изменения сцены — markChange (debounced) - this.blockManager.setOnChange(() => { - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - }); - this.modelManager.setOnChange(() => { - this.history?.markChange(); - // Сбрасываем spatial-индекс физики — модели могли двигаться/добавляться. - this.physics?.setSpatialDirty?.(); - if (this._onSceneChange) this._onSceneChange(); - }); - this.primitiveManager.setOnChange(() => { - this.history?.markChange(); - this.physics?.setSpatialDirty?.(); - if (this._onSceneChange) this._onSceneChange(); - }); - // Этап 5: подписка на изменения user-моделей. - this.userModelManager.setOnChange(() => { - this.history?.markChange(); - this.physics?.setSpatialDirty?.(); - if (this._onSceneChange) this._onSceneChange(); - }); - this.folderManager.setOnChange(() => { - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - }); - this.guiManager.setOnChange(() => { - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - if (this._onGuiChange) this._onGuiChange(); - // Если в Play — обновляем зеркало в Worker'ах сразу - if (this._isPlaying && this.gameRuntime) { - this.gameRuntime.scheduleGuiSnapshot(); - } - }); - this.inventory.setOnChange(() => { - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - if (this._onInventoryChange) this._onInventoryChange(); - }); - - // Запоминаем начальное (пустое) состояние как точку для undo. - this.history.initialize(); - - this.engine.runRenderLoop(() => { - // Если рендер на паузе (например, активен таб скрипта или вкладка - // браузера в фоне) — пропускаем тик целиком. Освобождаем CPU/GPU - // для Monaco, который иначе лагает на ввод. - if (this._renderingPaused) return; - if (this.scene && this.scene.activeCamera) { - this._updateCameraMovement(); - this._updateGhostPosition(); - const dt = this.engine.getDeltaTime() / 1000; - // Физика unanchored-объектов в Play-режиме - if (this._isPlaying && this.dynamics?.isEnabled()) { - this.dynamics.tick(dt); - } - // Цикл дня/ночи (только в Play-режиме, чтобы редактор не «убегал») - if (this._isPlaying && this.environment) { - this.environment.tick(dt); - } - // Анимация жидкостей — работает всегда (и в редакторе) - if (this.blockManager) { - this.blockManager.tick(dt); - } - // LOD/culling далёких моделей (раз в 5 кадров — экономим CPU) - this._lodFrameCounter = (this._lodFrameCounter || 0) + 1; - if (this._lodFrameCounter % 5 === 0) { - this._updateModelLOD(); - // Примитивы НЕ култим по дистанции — на компактных сценах - // (Squid Game) это убирает куклу/охранников вдали и пользователь - // видит пустое поле. Лучшее решение — пусть Babylon - // frustum-cull'ит сам, у нас уже freezeWorldMatrix. - } - // Этап 4 voxel-streaming: подгрузка/выгрузка чанков по радиусу - // от игрока (в Play) или камеры (в редакторе). Дёргаем раз в - // 250мс — этого достаточно при ходьбе. - // VoxelWorld streaming (новый рендер) — disabled by default, - // используется TerrainManager streaming ниже (legacy подход). - if (this._voxelStreamingEnabled && this._voxelRenderEnabled && this.voxelRenderer) { - const nowMs = performance.now(); - if (nowMs - this._voxelStreamingLastUpdate > 200) { - this._voxelStreamingLastUpdate = nowMs; - let cx, cz; - if (this._isPlaying && this.player && this.player._pos) { - cx = this.player._pos.x; cz = this.player._pos.z; - } else if (this.camera && this.camera.position) { - cx = this.camera.position.x; cz = this.camera.position.z; - } - if (cx !== undefined) { - this.voxelRenderer.updateStreaming({ x: cx, z: cz }, this._voxelStreamingRadius); - } - } - } - - // === LEGACY TerrainManager streaming (region-meshes) === - // Главный механизм производительности для больших карт: - // enable/disable region-meshes legacy террейна по радиусу - // от игрока/камеры. Регионы за пределами radius — disabled, - // не рендерятся GPU. - if (this._terrainStreamingEnabled && this.terrainManager?.updateStreaming) { - const nowMs2 = performance.now(); - // 200мс — реже чем раньше (было 80мс). Streaming = тяжёлая - // операция (обход всех region-meshes), не нужна каждые 80мс. - if (nowMs2 - (this._terrainStreamingLastUpdate || 0) > 200) { - this._terrainStreamingLastUpdate = nowMs2; - let cx, cz; - let radius = this._terrainStreamingRadius || 60; - if (this._isPlaying && this.player && this.player._pos) { - cx = this.player._pos.x; cz = this.player._pos.z; - } else if (this.camera && this.camera.position) { - cx = this.camera.position.x; cz = this.camera.position.z; - const camY = this.camera.position.y || 0; - // Editor radius = play × 1.3 + height bonus. Capped 60м. - // Раньше было ×1.6 + 30 = до 85м (47 регионов в кадре = 14M trianglов). - // Сейчас 32 × 1.3 + 20 = до 60м (~20-25 регионов = ~5M trianglов). - const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); - radius = Math.min(60, radius * 1.3 + heightBonus); - } - // SKIP если камера не сдвинулась >3м с прошлого пересчёта - const prevX = this._terrainStreamingPrevX; - const prevZ = this._terrainStreamingPrevZ; - if (cx !== undefined && prevX !== undefined) { - const ddx = cx - prevX, ddz = cz - prevZ; - if (ddx * ddx + ddz * ddz < 9) { // < 3м - cx = undefined; // отменяет следующий блок - } - } - if (cx !== undefined) { - this._terrainStreamingPrevX = cx; - this._terrainStreamingPrevZ = cz; - } - if (cx !== undefined) { - this.terrainManager.updateStreaming(cx, cz, radius); - // Этап D: deco streaming с МЕНЬШИМ радиусом. - // Декорации видны только вблизи. Минимум 35м чтобы - // chunk 64м не пропадал — иначе видны «дыры». - if (this.decoManager?.updateStreaming) { - const decoRadius = Math.max(18, radius * 0.35); - this.decoManager.updateStreaming(cx, cz, decoRadius); - } - } - } - } - // Задача 04: modalManager.tick — независимо от runtime'а - if (this._isPlaying && this.modalManager?.tick) { - try { this.modalManager.tick(dt); } catch (e) {} - } - // Tick пользовательских скриптов: в Play-режиме или в solo-debug - if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { - this.gameRuntime.tick(dt); - // Детекция touch-событий — раз в 3 кадра (для script onTouch). - // Это O(N×M) = скрипты × примитивы, на мобиле просаживало FPS - // при повороте. 3 кадра ≈ 50мс при 60fps — хватает для UX. - if (this._isPlaying) { - this._touchDetectFrame = (this._touchDetectFrame || 0) + 1; - if (this._touchDetectFrame >= 3) { - this._touchDetectFrame = 0; - this._detectTouchEvents(); - } - } - } - // Анимация полоски перезарядки оружия - if (this._isPlaying && this.weapons) this.weapons.tick(); - // PERF-METRICS: замеряем render() — обычно самая толстая часть. - const _rt0 = performance.now(); - // Замер idle-времени = промежуток между концом предыдущего - // render и началом текущего. Большое idle = GPU-bound. - if (this._perfMetrics && this._perfMetrics._lastRenderEnd > 0) { - const idle = _rt0 - this._perfMetrics._lastRenderEnd; - if (idle > 0 && idle < 1000) { - this._perfMetrics.idle_ms_sum += idle; - this._perfMetrics.idle_count++; - } - } - this.scene.render(); - if (this._perfMetrics) { - const _rt1 = performance.now(); - this._perfMetrics.render_ms_sum += _rt1 - _rt0; - this._perfMetrics.render_count++; - this._perfMetrics._lastRenderEnd = _rt1; - } - } - }); - - // resize при изменении окна - this._resizeHandler = () => this.engine.resize(); - window.addEventListener('resize', this._resizeHandler); - - // Главное: ResizeObserver на canvas. React сначала рендерит canvas - // размером 0, потом раскладка применяет 100%/100%. Engine, созданный - // в момент init, считал размер 0 и backbuffer был пуст. Через RO - // ловим финальный размер и вызываем resize. - if (typeof ResizeObserver !== 'undefined') { - this._ro = new ResizeObserver(() => { - if (this.engine) this.engine.resize(); - }); - this._ro.observe(this.canvas); - } - - // Принудительный resize чуть позже — на случай если RO не сработал - setTimeout(() => { if (this.engine) this.engine.resize(); }, 100); - } - - /** - * UniversalCamera — позволяет ручное управление позицией и yaw/pitch. - * Стартовая позиция: смотрим на (0,0,0) сверху-сбоку. - */ - _createCamera() { - const camera = new UniversalCamera( - 'editorCamera', - new Vector3(15, 15, -20), - this.scene - ); - camera.setTarget(new Vector3(0, 0, 0)); - camera.minZ = 0.1; - camera.maxZ = 1000; - camera.fov = 0.9; - - // ОТКЛЮЧАЕМ стандартное управление — будем писать своё. - camera.inputs.clear(); - - this.camera = camera; - } - - _createLights() { - const hemi = new HemisphericLight( - 'hemiLight', - new Vector3(0, 1, 0), - this.scene - ); - hemi.intensity = 0.65; - hemi.groundColor = new Color3(0.3, 0.3, 0.4); - - const sun = new DirectionalLight( - 'sunLight', - new Vector3(-0.5, -1, -0.3), - this.scene - ); - sun.intensity = 0.8; - sun.position = new Vector3(20, 40, 20); - - // Сохраняем ссылки чтобы Environment мог менять их свойства - this._hemiLight = hemi; - this._sunLight = sun; - - // Тени — по умолчанию мягкие. Создаётся ShadowGenerator при первом - // вызове setShadowQuality, либо сразу через _ensureShadowGenerator. - // MOBILE-OPT (этап 1.5): на мобильном тени = 'hard' (жёсткие — без - // soft-blur, дешевле). 'off' давало плоскую картинку, теперь - // компромисс — есть тени но дешевле soft. - this._shadowQuality = this._isMobileMode ? 'hard' : 'soft'; - this._shadowGenerator = null; - this._ensureShadowGenerator(); - - // SSAO2 — Screen-Space Ambient Occlusion (контактные тени в углах, - // под объектами и в стыках). По умолчанию выключен — это дорогой - // пост-эффект (-15..30% FPS). Включается через setSsaoEnabled(true) / - // setLightingProps({ ssaoEnabled: true }) из инспектора Lighting. - this._ssaoPipeline = null; - this._ssaoEnabled = false; - } - - /** - * Включить/выключить SSAO пост-эффект (контактные тени). - * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер - * (блоки/террейн пропадали) из-за GeometryBufferRenderer. v1 использует - * только depthRenderer и совместим со всеми мешами. - */ - setSsaoEnabled(on) { - const want = !!on; - if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return; - if (!want) { - this._disposeSsaoPipeline(); - this._ssaoEnabled = false; - return; - } - if (!this.scene.activeCamera) { - console.warn('[BabylonScene] SSAO: нет активной камеры'); - return; - } - try { - const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 }; - const pipeline = new SSAORenderingPipeline( - 'ssaopipeline', this.scene, ratio, [this.scene.activeCamera] - ); - pipeline.fallOff = 0.000001; - pipeline.area = 0.0075; - pipeline.radius = 0.0001; - pipeline.totalStrength = 1.0; - pipeline.base = 0.5; - this._ssaoPipeline = pipeline; - this._ssaoEnabled = true; - } catch (e) { - console.warn('[BabylonScene] SSAO не запустился:', e?.message || e); - this._disposeSsaoPipeline(); - this._ssaoEnabled = false; - } - } - - /** Полностью убрать SSAO пайплайн (detach + remove + dispose). */ - _disposeSsaoPipeline() { - if (!this._ssaoPipeline) return; - const mgr = this.scene.postProcessRenderPipelineManager; - const name = this._ssaoPipeline.name || 'ssaopipeline'; - try { - if (mgr && this.scene.activeCamera) { - mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera); - } - } catch (e) { /* ignore */ } - try { - if (mgr && typeof mgr.removePipeline === 'function') { - mgr.removePipeline(name); - } - } catch (e) { /* ignore */ } - try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ } - this._ssaoPipeline = null; - } - - getSsaoEnabled() { return this._ssaoEnabled; } - - /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество. - * - * Поддерживаемые уровни (Этап 2 теней, 2026-05-27): - * - 'off' — теней нет - * - 'hard' — резкие тени, 512px, без блюра - * - 'soft' — мягкие тени, 1024px (на mobile 512), blurKernel 24 - * - 'medium' — CSM 1024 × 3 каскада, для среднего ПК - * - 'high' — CSM 2048 × 4 каскада, дорогой, для топовых ПК - */ - _ensureShadowGenerator() { - const q = this._shadowQuality; - if (q === 'off') { - if (this._shadowGenerator) { - try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } - this._shadowGenerator = null; - } - return null; - } - // Если уже создан — но качество поменялось на тот тип где нужен другой - // движок (CSM vs обычный) — пересоздадим. - const wantCsm = (q === 'medium' || q === 'high'); - const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator; - if (this._shadowGenerator && wantCsm !== haveCsm) { - try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } - this._shadowGenerator = null; - } - - // PCF = Percentage Closer Filtering. Правильная техника мягких теней. - // - // bias 0.0005, normalBias 0.005. Раньше normalBias=0.012 давал - // peter-panning — тень "уезжала" далеко в сторону от блока (баг - // 2026-05-27). 0.005 — баланс между acne и peter-panning для - // воксельных кубов 1м. - const PCF_BIAS = 0.0005; - const PCF_NORMAL_BIAS = 0.005; - - if (!this._shadowGenerator) { - if (wantCsm) { - // CSM с PCF. Поднял разрешение каскадов (2048/4096 — было 1024/2048). - const size = (q === 'high') ? 4096 : 2048; - const numCascades = (q === 'high') ? 4 : 3; - const csm = new CascadedShadowGenerator(size, this._sunLight); - csm.numCascades = numCascades; - csm.stabilizeCascades = true; - csm.lambda = 0.8; - csm.cascadeBlendPercentage = 0.07; - csm.shadowMaxZ = (q === 'high') ? 200 : 120; - csm.bias = PCF_BIAS; - csm.normalBias = PCF_NORMAL_BIAS; - csm.usePercentageCloserFiltering = true; - csm.filteringQuality = (q === 'high') - ? ShadowGenerator.QUALITY_HIGH - : ShadowGenerator.QUALITY_MEDIUM; - csm.darkness = 0.4; - csm.autoCalcDepthBounds = true; - this._shadowGenerator = csm; - } else { - // Обычный ShadowGenerator. Soft теперь 2048 (было 1024). - let shadowSize; - if (q === 'hard') { - shadowSize = this._isMobileMode ? 512 : 1024; - } else { // soft - shadowSize = this._isMobileMode ? 1024 : 2048; - } - const gen = new ShadowGenerator(shadowSize, this._sunLight); - gen.bias = PCF_BIAS; - gen.normalBias = PCF_NORMAL_BIAS; - if (gen.getShadowMap) { - const rtt = gen.getShadowMap(); - if (rtt) rtt.refreshRate = 2; - } - this._shadowGenerator = gen; - } - } - - const gen = this._shadowGenerator; - if (q === 'medium' || q === 'high') { - // параметры CSM выставлены при создании - } else if (q === 'soft') { - gen.usePercentageCloserFiltering = true; - gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM; - gen.useBlurExponentialShadowMap = false; - gen.useKernelBlur = false; - gen.usePoissonSampling = false; - gen.darkness = 0.4; - } else { // hard - gen.usePercentageCloserFiltering = false; - gen.useBlurExponentialShadowMap = false; - gen.useKernelBlur = false; - gen.usePoissonSampling = false; - gen.darkness = 0.55; - } - return gen; - } - - /** - * Изменить качество теней. 'off' уничтожает генератор; 'hard'/'soft'/ - * 'medium'/'high' создают/обновляют. CSM используется для medium/high. - */ - setShadowQuality(q) { - const allowed = ['off', 'hard', 'soft', 'medium', 'high']; - if (!allowed.includes(q)) return; - this._shadowQuality = q; - this._ensureShadowGenerator(); - // Если выключили — снимем receiveShadows с пола (необязательно, но чище) - const ground = this.scene.getMeshByName('editorGround'); - if (ground) ground.receiveShadows = q !== 'off'; - // После смены качества — заново зарегистрировать всех casters - // (при пересоздании генератора список обнулился). - if (q !== 'off') { - try { this.refreshAllShadows(); } catch (e) { /* ignore */ } - } - } - - getShadowQuality() { return this._shadowQuality || 'soft'; } - - /** - * Установить свойства глобального освещения. Вызывается из Inspector - * (selection.type === 'lighting'). - * patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?, - * fogDensity?, fogColor?, shadowQuality? } - */ - setLightingProps(patch) { - if (!patch) return; - // Время суток — пресет / минуты день/ночь - if (patch.envPreset && this.environment) { - try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ } - } - if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) { - this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin); - } - if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) { - this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin); - } - if (typeof patch.sunIntensity === 'number' && this._sunLight) { - this._sunLight.intensity = Math.max(0, patch.sunIntensity); - } - if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { - this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); - } - if (this.environment && typeof this.environment.setFog === 'function') { - // Текущие значения берём из Environment, поверх накладываем patch - const enabled = (typeof patch.fogEnabled === 'boolean') - ? patch.fogEnabled : this.environment.fogEnabled; - let color = this.environment.fogColor; - if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) { - color = [ - parseInt(patch.fogColor.substr(1, 2), 16) / 255, - parseInt(patch.fogColor.substr(3, 2), 16) / 255, - parseInt(patch.fogColor.substr(5, 2), 16) / 255, - ]; - } - const density = (typeof patch.fogDensity === 'number') - ? patch.fogDensity : this.environment.fogDensity; - if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) { - this.environment.setFog(enabled, color, density); - } - } - if (patch.shadowQuality) { - this.setShadowQuality(patch.shadowQuality); - this.refreshAllShadows(); - } - if (typeof patch.ssaoEnabled === 'boolean') { - this.setSsaoEnabled(patch.ssaoEnabled); - } - // Обновить selection чтобы Inspector сразу показывал новые значения - if (this.selection?._selection?.type === 'lighting') { - this.selection.selectLighting(); - } - } - - /** - * Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G). - * Если выделен один — кладёт его одного. Если ничего — no-op. - */ - groupSelected(name = null) { - if (!this.folderManager || !this.selection) return null; - const multi = this.selection.getMultiSelection(); - const items = []; - if (multi.length > 0) { - for (const it of multi) items.push(it); - } else { - const s = this.selection.getSelection(); - if (!s) return null; - if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); - else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId }); - else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id }); - else return null; - } - if (items.length === 0) return null; - const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`; - const folderId = this.folderManager.createFolder(folderName, null); - for (const it of items) { - this.folderManager.assignToFolder(it.kind, it.ref, folderId); - } - return folderId; - } - - /** - * Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно. - * ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы. - * ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode - * этих методов нет, что приводит к runtime-крашу. - */ - /** Удалено: пытались через ShadowGenerator, не сработало. - * Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */ - _enableGdShadows() { /* no-op */ } - - addShadowCaster(mesh) { - if (!this._shadowGenerator || !mesh) return; - // TransformNode не имеет getBoundingInfo/getVerticesData - if (typeof mesh.getBoundingInfo !== 'function') return; - if (typeof mesh.getTotalVertices !== 'function') return; - if (mesh.getTotalVertices() <= 0) return; - try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } - } - - /** - * Зарегистрировать все текущие блоки/модели/примитивы как shadow casters. - * Полезно вызвать после loadFromState или смены качества теней. - */ - refreshAllShadows() { - if (!this._shadowGenerator) return; - if (this.blockManager) { - // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы - if (this.blockManager._protoMeshes) { - for (const proto of this.blockManager._protoMeshes.values()) { - this.addShadowCaster(proto); - } - } - // Жидкости/legacy mesh - for (const m of this.blockManager.blocks.values()) { - if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m); - } - } - if (this.modelManager) { - for (const inst of this.modelManager.instances.values()) { - const root = inst.rootMesh; - if (!root) continue; - // root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы - if (typeof root.getChildMeshes === 'function') { - for (const cm of root.getChildMeshes()) this.addShadowCaster(cm); - } - } - } - if (this.primitiveManager) { - for (const inst of this.primitiveManager.instances.values()) { - if (inst.mesh) this.addShadowCaster(inst.mesh); - } - } - } - - _createGroundGrid() { - // Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF. - const WORLD_HALF = this._worldHalf; - - const ground = MeshBuilder.CreateGround( - 'editorGround', - { width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 }, - this.scene - ); - - // Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн. - // Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует - // 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат. - const TEX_SIZE = 64; - const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false); - baseTex.wrapU = 1; // wrap - baseTex.wrapV = 1; - baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки - baseTex.vScale = WORLD_HALF * 2 / 4; - baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE); - const ctx = baseTex.getContext(); - // Серая основа - ctx.fillStyle = '#7a8071'; - ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE); - // Тёмные «рейки» по периметру плитки (Roblox-style) - ctx.strokeStyle = '#5d6358'; - ctx.lineWidth = 4; - ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4); - // Тонкая внутренняя сетка 4 на плитке - ctx.strokeStyle = '#6c7268'; - ctx.lineWidth = 1; - for (let i = 1; i < 4; i++) { - const p = (i * TEX_SIZE) / 4; - ctx.beginPath(); - ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2); - ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p); - ctx.stroke(); - } - baseTex.update(); - - const mat = new StandardMaterial('groundMat', this.scene); - mat.diffuseTexture = baseTex; - mat.specularColor = new Color3(0, 0, 0); - ground.material = mat; - ground.receiveShadows = true; - - // Только две осевые линии (X и Z), цветные — для ориентации в редакторе. - // Сетку делает сама baseplate-текстура. - const axisMatX = new StandardMaterial('axisMatX', this.scene); - axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25); - axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1); - axisMatX.specularColor = new Color3(0, 0, 0); - - const axisMatZ = new StandardMaterial('axisMatZ', this.scene); - axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85); - axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5); - axisMatZ.specularColor = new Color3(0, 0, 0); - - // Ось X (красная) — линия вдоль X на z=0 - const axisX = MeshBuilder.CreateBox('axisX', - { width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene); - axisX.position = new Vector3(0, 0.011, 0); - axisX.material = axisMatX; - axisX.isPickable = false; - this._gridLines = [axisX]; - - // Ось Z (синяя) — линия вдоль Z на x=0 - const axisZ = MeshBuilder.CreateBox('axisZ', - { width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene); - axisZ.position = new Vector3(0, 0.011, 0); - axisZ.material = axisMatZ; - axisZ.isPickable = false; - this._gridLines.push(axisZ); - } - - /** - * Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии. - * @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500). - */ - setWorldSize(worldSize) { - const half = Math.max(10, Math.round(worldSize / 2)); - if (half === this._worldHalf) return; - this._worldHalf = half; - // ВАЖНО: physics.floorHalf по умолчанию 40. Если визуальная плита - // больше — игрок проваливается за пределами центрального 80×80 - // квадрата. Синхронизируем физику с визуалом. - if (this.physics) this.physics.floorHalf = half; - // Удалить старый пол + осевые линии - const oldGround = this.scene.getMeshByName('editorGround'); - if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ } - if (Array.isArray(this._gridLines)) { - for (const line of this._gridLines) { - try { line.dispose(); } catch (e) { /* ignore */ } - } - } - this._gridLines = []; - this._createGroundGrid(); - } - - /** Текущий размер пола в юнитах (worldSize, не worldHalf). */ - getWorldSize() { return this._worldHalf * 2; } - - /** Включить/выключить пол (визуально и физически). */ - setFloorEnabled(enabled) { - this._floorEnabled = !!enabled; - if (!this.scene) return; - const ground = this.scene.getMeshByName('editorGround'); - if (ground) ground.setEnabled(this._floorEnabled); - // Линии осей тоже - if (Array.isArray(this._gridLines)) { - for (const line of this._gridLines) { - if (line && line.setEnabled) line.setEnabled(this._floorEnabled); - } - } - // Физика: отключаем коллизию с baseplate, чтобы игрок проваливался - if (this.physics) this.physics.floorEnabled = this._floorEnabled; - } - isFloorEnabled() { return this._floorEnabled !== false; } - - /** - * Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks, - * отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию. - * Вызывается из UI (кнопка «✖» в Генератор-панели). - */ - clearRobloxTerrain() { - let hadTerrain = false; - if (this._robloxTerrain) { - try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {} - // ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации - // решит что terrain уже есть и НЕ переподключит его к physics. - this._robloxTerrain = null; - } - // Декорации тоже чистим (thin-instances очищаются, prototype остаётся - // в памяти для следующего применения). - if (this._smoothDecoManager) { - try { this._smoothDecoManager.clear(); } catch (e) {} - } - this._smoothDecoParams = null; - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(null); - } - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(null); - } - try { this.setFloorEnabled(true); } catch (e) {} - this._spawnPoint = { x: 0, y: 5, z: 0 }; - try { this._updateSpawnMarker?.(); } catch (e) {} - // Прячем мини-карту гладкого ландшафта — grid больше нет. - if (window.__robloxMinimapGrid) { - window.__robloxMinimapGrid = null; - this._terrainStreamingEnabled = false; - } - // Помечаем dirty чтобы автосейв записал пустой robloxTerrain - try { this._onSceneChange?.(); } catch (e) {} - console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`); - } - - /** - * Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше. - * Применяется при enterPlayMode и через player.setJumpPower. - */ - setPlayerJumpPower(mul) { - const m = Math.max(0.2, Math.min(5, Number(mul) || 1)); - this._jumpPowerMul = m; - if (this.player) this.player._jumpPowerMul = m; - } - getPlayerJumpPower() { return this._jumpPowerMul ?? 1; } - - /** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */ - setCrosshair(type) { - const allowed = ['none', 'dot', 'cross', 'circle']; - if (!allowed.includes(type)) return; - this._crosshair = type; - } - getCrosshair() { return this._crosshair || 'none'; } - - /** - * LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую - * матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера. - * Запускается в render-loop из tick(). - */ - _updateModelLOD() { - if (!this.modelManager || !this.camera) return; - const cam = this.camera.position; - const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix - const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE; - const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем - // (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере) - const LOD_CULL_SQ = LOD_CULL * LOD_CULL; - for (const data of this.modelManager.instances.values()) { - const root = data.rootMesh; - if (!root) continue; - // Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ - // подвергаем LOD-freeze — за ними двигает свой менеджер. - const gp = data.gameplay; - if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue; - const dx = root.position.x - cam.x; - const dy = root.position.y - cam.y; - const dz = root.position.z - cam.z; - const distSq = dx * dx + dy * dy + dz * dz; - // Cull - const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false; - if (shouldCull && root._kubikonCulled !== true) { - root.setEnabled(false); - root._kubikonCulled = true; - } else if (!shouldCull && root._kubikonCulled === true) { - root.setEnabled(data.visible !== false); - root._kubikonCulled = false; - } - // Freeze - const shouldFreeze = distSq > LOD_FREEZE_SQ; - if (shouldFreeze && root._kubikonFrozen !== true) { - try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ } - root._kubikonFrozen = true; - } else if (!shouldFreeze && root._kubikonFrozen === true) { - try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ } - root._kubikonFrozen = false; - } - } - } - - /** - * LOD-cull для примитивов: далёкие декорации скрываем, ближние видны. - * Только в Play-режиме (в редакторе пользователь должен видеть всю сцену - * чтобы редактировать). На больших проектах (Only Up: 568 примитивов на - * вертикальной башне) это критично — без LOD при повороте камеры Babylon - * frustum-cull'ит сотни мешей и FPS падает в пол. - */ - _updatePrimitiveLOD() { - if (!this._isPlaying) return; - if (!this.primitiveManager || !this.camera) return; - const cam = this.camera.position; - const CULL = 120; - const CULL_SQ = CULL * CULL; - for (const data of this.primitiveManager.instances.values()) { - const m = data.mesh; - if (!m) continue; - // Не трогаем явно скрытые/невидимые скриптом - if (data.visible === false) continue; - const dx = data.x - cam.x; - const dy = data.y - cam.y; - const dz = data.z - cam.z; - const distSq = dx * dx + dy * dy + dz * dz; - const shouldCull = distSq > CULL_SQ; - if (shouldCull && m._kubikonPrimCulled !== true) { - m.setEnabled(false); - m._kubikonPrimCulled = true; - } else if (!shouldCull && m._kubikonPrimCulled === true) { - m.setEnabled(true); - m._kubikonPrimCulled = false; - } - } - } - - /** - * Roblox-style input handlers. - * Мышиные события — на canvas (только когда мышь над сценой). - * Клавиатурные — на window (работают при любом фокусе, как в реальных - * 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF) - * чтобы клавиши работали на любой раскладке (русская/английская). - */ - _setupInputControls() { - const canvas = this.canvas; - - // === МЫШЬ === - // mousedown на canvas в capture-фазе → срабатывает первым, - // даже если поверх есть другие listeners. - const onMouseDown = (e) => { - if (this._isPlaying) { - // В Play-режиме ЛКМ — клик игрока в forward-направлении. - // При pointer-lock курсор в центре; в third (свободный курсор) - // передаём реальные координаты клика для pick по табличкам. - if (e.button === 0) { - const r = canvas.getBoundingClientRect(); - this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); - } - return; - } - // Обновляем pointer координаты для raycast и Gizmo - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - - // Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо — - // отдаём событие Babylon GizmoManager и выходим (не ставим блок). - // Проверка attachedMesh || attachedNode — у разных версий Babylon - // и при attachToMesh vs attachToNode заполняется разное поле. - const hasAttachment = this._gizmo && - (this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode); - if (e.button === 0 && hasAttachment) { - const ulScene = this._gizmoLayer?.utilityLayerScene; - if (ulScene) { - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - if (ulPick && ulPick.hit) { - // Симулируем pointer-events для GizmoManager на utility-scene - ulScene.simulatePointerDown(ulPick); - this._gizmoDragging = true; - e.preventDefault(); - return; - } - } - } - - // Запоминаем стартовую точку любого нажатия — для drag-detection. - this._mouseDownButton = e.button; - this._mouseDownX = e.clientX; - this._mouseDownY = e.clientY; - this._mouseDownTime = Date.now(); - - // ЛКМ + tool=block/erase → активируем drag-постановку. - // Сразу же ставим первый блок в клетке под курсором. - if (e.button === 0 && !e.shiftKey - && (this._activeTool === 'block' || this._activeTool === 'erase')) { - this._isDragPlacing = true; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._dragPlaceTick(false, /*isFirst*/ true); - e.preventDefault(); - } - // ЛКМ + tool=terrain → активируем drag-кисть террейна. - // Shift модификатор обрабатывается внутри _terrainBrushTick (стирание). - else if (e.button === 0 && this._activeTool === 'terrain') { - this._isTerrainBrushing = true; - this._terrainDragLockY = null; - this._terrainHistoryOpen(); // снапшот для undo - this._setTerrainBrushPreviewActive(true); - this._terrainBrushTick(e.shiftKey, /*isFirst*/ true); - e.preventDefault(); - } - // Shift+ЛКМ — drag-удаление (даже если tool=block) - else if (e.button === 0 && e.shiftKey) { - this._isDragPlacing = true; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._dragPlaceTick(true, /*isFirst*/ true); - e.preventDefault(); - } - - if (e.button === 2) { - this._isRotating = true; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - canvas.style.cursor = 'grabbing'; - e.preventDefault(); - e.stopPropagation(); - } else if (e.button === 1) { - this._isPanning = true; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - canvas.style.cursor = 'move'; - e.preventDefault(); - e.stopPropagation(); - } - // ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup - // только если был "клик" (не drag). - }; - - // mouseup и mousemove — на window, чтобы drag работал даже когда - // курсор вышел за пределы canvas (стандартное поведение для drag). - const onMouseMove = (e) => { - // Babylon без detachControl сам не пишет в scene.pointerX/Y — - // делаем это руками. Нужны для raycast (scene.pick) и для гизмо. - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - - // Если идёт drag гизмо — проксируем move в utility-scene - if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { - const ulScene = this._gizmoLayer.utilityLayerScene; - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - ulScene.simulatePointerMove(ulPick); - return; - } - - // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке - if (this._isDragPlacing) { - this._dragPlaceTick(e.shiftKey); - return; - } - - // Если идёт drag-кисть террейна — продолжаем рисовать. - // Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера - // зависает в точке первого клика, пока юзер водит мышью. - if (this._isTerrainBrushing) { - this._terrainBrushTick(e.shiftKey, /*isFirst*/ false); - this._updateTerrainBrushPosition(); - return; - } - - // Когда tool=terrain без drag'а — подвигаем preview-меш под курсор - if (this._activeTool === 'terrain') { - this._updateTerrainBrushPosition(); - } - - if (!this._isRotating && !this._isPanning) return; - const dx = e.clientX - this._lastMouseX; - const dy = e.clientY - this._lastMouseY; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - - if (this._isRotating) { - this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY; - this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY; - const limit = Math.PI / 2 - 0.05; - if (this.camera.rotation.x > limit) this.camera.rotation.x = limit; - if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit; - } else if (this._isPanning) { - const right = this._getCameraRight(); - const up = this._getCameraUp(); - this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY)); - this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY)); - } - }; - - const onMouseUp = (e) => { - // Если идёт drag гизмо — отдаём pointerup и завершаем - if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { - const ulScene = this._gizmoLayer.utilityLayerScene; - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - ulScene.simulatePointerUp(ulPick); - this._gizmoDragging = false; - this._mouseDownButton = -1; - return; - } - // Если был drag-кисть террейна — сбрасываем флаг - if (this._isTerrainBrushing) { - this._isTerrainBrushing = false; - this._terrainDragLockY = null; - this._smoothBrushLockY = null; - this._smoothBrushLastPos = null; - this._terrainHistoryClose(); // фиксируем undo-снапшот - this._setTerrainBrushPreviewActive(false); - this._mouseDownButton = -1; - return; - } - // Если был drag-place — просто сбрасываем флаг, клик не обрабатываем - // (первая постановка уже сделана при mousedown). - if (this._isDragPlacing) { - this._isDragPlacing = false; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._mouseDownButton = -1; - return; - } - // Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно) - // → это клик; обрабатываем как редактор-клик (поставить/удалить блок). - if (e.button === 0 && this._mouseDownButton === 0) { - const dx = Math.abs(e.clientX - this._mouseDownX); - const dy = Math.abs(e.clientY - this._mouseDownY); - const dt = Date.now() - this._mouseDownTime; - if (dx < 4 && dy < 4 && dt < 400) { - this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey); - } - } - this._mouseDownButton = -1; - - if (e.button === 2) { - this._isRotating = false; - canvas.style.cursor = 'default'; - } else if (e.button === 1) { - this._isPanning = false; - canvas.style.cursor = 'default'; - } - }; - - const onWheel = (e) => { - e.preventDefault(); - const forward = this._getCameraForward(); - const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; - this.camera.position.addInPlace(forward.scale(delta)); - }; - - const onContextMenu = (e) => { - e.preventDefault(); - }; - - // === КЛАВИАТУРА === - // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки. - // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...), - // поэтому надёжно использовать только e.code. - - /** - * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable. - * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод. - * Также игнорируем когда открыта модалка (z-index overlay). - */ - const isTypingTarget = (target) => { - if (!target) return false; - const tag = (target.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; - if (target.isContentEditable) return true; - return false; - }; - - const onKeyDown = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.add(e.code); - if (e.shiftKey) this._shiftDown = true; - // Маршрутизация game.onKey в Play-режиме - if (this._isPlaying && this.gameRuntime) { - const key = this._normalizeKey(e); - this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); - } - if (e.code === 'KeyF') { - this._focusOnTarget(new Vector3(0, 0, 0)); - } - // Ctrl+D — дублировать выделенное - if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.duplicateSelected(); - return; - } - // Ctrl+C — копировать выделенное в буфер (Фаза 5.10). - if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.copySelected(); - return; - } - // Ctrl+V — вставить из буфера (работает и между проектами). - if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.pasteFromClipboard(); - return; - } - // Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo - if ((e.ctrlKey || e.metaKey) && !this._isPlaying) { - if (e.code === 'KeyZ' && !e.shiftKey) { - e.preventDefault(); - this.undo?.(); - return; - } - if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') { - e.preventDefault(); - this.redo?.(); - return; - } - // Ctrl+G — сгруппировать выделенное в новую папку - if (e.code === 'KeyG') { - e.preventDefault(); - this.groupSelected(); - return; - } - // Ctrl+A — выделить всё - if (e.code === 'KeyA') { - e.preventDefault(); - this.selection?.selectAll(); - return; - } - } - - // R — повернуть ghost-модель на 90° (или выделенную модель) - if (e.code === 'KeyR' && !this._isPlaying) { - const sel = this.selection?.getSelection(); - if (sel?.type === 'model') { - const newAngle = (sel.rotationY || 0) + Math.PI / 2; - this.selection.rotateSelectedModel(newAngle); - } else if (this._activeTool === 'model') { - this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2); - } - } - // Delete / Backspace — удалить выделенный - if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) { - // Приоритет: выбранная инструментом «Выбрать деко» декорация. - if (this._decoSelection) { - this._deleteSelectedDeco(); - e.preventDefault(); - } else if (this.selection?.getSelection()) { - this.selection.deleteSelected(); - e.preventDefault(); - } - } - // Escape — снять выделение + переключиться на инструмент «Выделить» - // (в режиме игры Esc обрабатывает PlayerController — выход из Play). - if (e.code === 'Escape' && !this._isPlaying) { - this.selection?.clear(); - if (this._onEditorEscape) { - try { this._onEditorEscape(); } catch (err) { /* ignore */ } - } - } - if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { - e.preventDefault(); - } - }; - - const onKeyUp = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.delete(e.code); - if (!e.shiftKey) this._shiftDown = false; - if (this._isPlaying && this.gameRuntime) { - const key = this._normalizeKey(e); - this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code }); - } - }; - - const onBlur = () => { - this._codes.clear(); - this._shiftDown = false; - this._isRotating = false; - this._isPanning = false; - canvas.style.cursor = 'default'; - }; - - // Регистрация: - // - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место - // для перехвата мыши над сценой; наш обработчик отрабатывает первым, - // до Babylon-овских стандартных listeners. - // - keydown/keyup — на window (клавиатуру всегда слушаем глобально). - // - wheel/contextmenu — на canvas в capture. - canvas.addEventListener('mousedown', onMouseDown, true); - canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); - canvas.addEventListener('contextmenu', onContextMenu, true); - // mousemove/mouseup на window — для drag за пределами canvas. - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - window.addEventListener('blur', onBlur); - - this._listeners = [ - { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, - { target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } }, - { target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true }, - { target: window, type: 'mousemove', fn: onMouseMove }, - { target: window, type: 'mouseup', fn: onMouseUp }, - { target: window, type: 'keydown', fn: onKeyDown }, - { target: window, type: 'keyup', fn: onKeyUp }, - { target: window, type: 'blur', fn: onBlur }, - ]; - } - - /** - * Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ - * (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены). - * ПКМ нужна только для поворота камеры. - * Вызывается каждый кадр из render loop. - * Используем e.code — независимо от раскладки клавиатуры. - */ - _updateCameraMovement() { - if (this._isPlaying) return; // в режиме игры редактор-камера не движется - const c = this._codes; - if (c.size === 0) return; - - const dt = this.engine.getDeltaTime() / 1000; - const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1); - - const forward = this._getCameraForward(); - const right = this._getCameraRight(); - const worldUp = new Vector3(0, 1, 0); - const move = Vector3.Zero(); - - if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed)); - if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed)); - if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed)); - if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed)); - if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed)); - if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed)); - - if (move.lengthSquared() > 0) { - this.camera.position.addInPlace(move); - } - } - - /** - * Единичный вектор «вперёд» камеры (с учётом её поворота). - */ - _getCameraForward() { - const yaw = this.camera.rotation.y; - const pitch = this.camera.rotation.x; - return new Vector3( - Math.sin(yaw) * Math.cos(pitch), - -Math.sin(pitch), - Math.cos(yaw) * Math.cos(pitch) - ).normalize(); - } - - /** - * Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости). - */ - _getCameraRight() { - const yaw = this.camera.rotation.y; - return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize(); - } - - /** - * Единичный вектор «вверх» относительно камеры. - */ - _getCameraUp() { - const forward = this._getCameraForward(); - const right = this._getCameraRight(); - return Vector3.Cross(right, forward).normalize(); - } - - /** - * Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению. - * Будет использоваться для F — focus on selected. - */ - _focusOnTarget(target) { - const offset = this._getCameraForward().scale(-15); - this.camera.position = target.add(offset); - this.camera.setTarget(target); - } - - // === ИНСТРУМЕНТЫ И БЛОКИ =========================================== - - /** - * Создать "призрачный" блок — полупрозрачный преview, показывает где - * появится блок при клике. - */ - _createGhostBlock() { - const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene); - const mat = new StandardMaterial('ghostMat', this.scene); - mat.diffuseColor = new Color3(0.4, 0.9, 0.4); - mat.alpha = 0.35; - mat.specularColor = new Color3(0, 0, 0); - mat.disableLighting = true; - ghost.material = mat; - ghost.isPickable = false; // raycast его игнорирует - ghost.setEnabled(false); - this._ghostMesh = ghost; - } - - /** - * Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со - * светящейся вершиной. Виден только в редакторе, скрывается в Play. - */ - _createSpawnMarker() { - // Базовый цилиндр-подставка - const base = MeshBuilder.CreateCylinder( - 'spawnMarkerBase', - { diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 }, - this.scene - ); - const baseMat = new StandardMaterial('spawnBaseMat', this.scene); - baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2); - baseMat.emissiveColor = new Color3(0.3, 0.2, 0); - baseMat.specularColor = new Color3(0, 0, 0); - baseMat.alpha = 0.85; - base.material = baseMat; - - // Внутренний светящийся столб - const beam = MeshBuilder.CreateCylinder( - 'spawnMarkerBeam', - { diameter: 0.4, height: 2.5, tessellation: 16 }, - this.scene - ); - const beamMat = new StandardMaterial('spawnBeamMat', this.scene); - beamMat.diffuseColor = new Color3(1, 0.9, 0.3); - beamMat.emissiveColor = new Color3(1, 0.85, 0.2); - beamMat.specularColor = new Color3(0, 0, 0); - beamMat.alpha = 0.4; - beamMat.disableLighting = true; - beam.material = beamMat; - beam.position.y = 1.3; - - // Группируем base+beam в TransformNode чтобы двигать как одно - const root = new TransformNode('spawnMarker', this.scene); - base.parent = root; - beam.parent = root; - root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z); - - // Делаем маркер pickable, чтобы можно было кликнуть и выделить. - // Метаданные для SelectionManager: { isSpawn: true }. - base.isPickable = true; - beam.isPickable = true; - base.metadata = { isSpawn: true }; - beam.metadata = { isSpawn: true }; - - this._spawnMarker = root; - this._spawnMarkerMeshes = [base, beam]; - } - - /** Обновить позицию визуального маркера спавна. */ - _updateSpawnMarker() { - if (!this._spawnMarker) return; - this._spawnMarker.position.set( - this._spawnPoint.x, - this._spawnPoint.y, - this._spawnPoint.z - ); - } - - /** Скрыть/показать маркер спавна. */ - _setSpawnMarkerVisible(visible) { - if (!this._spawnMarker) return; - this._spawnMarker.setEnabled(visible); - // КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым. - // Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если - // disabled у parent TransformNode. Без isPickable=false луч стрельбы - // попадает в столб маркера в 5м перед игроком. - if (this._spawnMarkerMeshes) { - for (const m of this._spawnMarkerMeshes) { - if (m) m.isPickable = visible; - } - } - } - - /** - * Raycast от курсора в сцену. - * Возвращает { mesh, point, normal } либо null если ни во что не попали. - * Игнорирует ghost-блок и линии сетки. - */ - /** - * Нормализация клавиши из KeyboardEvent в простую строку для game.onKey. - * KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ... - */ - _normalizeKey(e) { - const code = e.code || ''; - // Буквы KeyA..KeyZ → 'a'..'z' - if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase(); - // Цифры Digit0..Digit9 → '0'..'9' - if (/^Digit\d$/.test(code)) return code.charAt(5); - // Спецклавиши - const map = { - Space: 'space', - Enter: 'enter', - NumpadEnter: 'enter', - Escape: 'escape', - Tab: 'tab', - Backspace: 'backspace', - ShiftLeft: 'shift', ShiftRight: 'shift', - ControlLeft: 'ctrl', ControlRight: 'ctrl', - AltLeft: 'alt', AltRight: 'alt', - ArrowUp: 'arrowup', ArrowDown: 'arrowdown', - ArrowLeft: 'arrowleft', ArrowRight: 'arrowright', - }; - if (map[code]) return map[code]; - // Fallback — сам key в lower-case - return String(e.key || code).toLowerCase(); - } - - /** - * Pick по центру экрана (для Play-режима где курсор залочен). - * Используется для game.self.onClick — клик луч-форвард игрока. - */ - _pickFromCenter() { - const w = this.engine?.getRenderWidth?.() || this.canvas.width; - const h = this.engine?.getRenderHeight?.() || this.canvas.height; - const pi = this.scene.pick(w / 2, h / 2, (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - return true; - }); - if (!pi || !pi.hit) return null; - let mesh = pi.pickedMesh; - if (mesh?.metadata?._isBlockProto && this.blockManager) { - const proxy = this.blockManager.findProxyByPickInfo(pi); - if (proxy) mesh = proxy; - } - return { mesh, point: pi.pickedPoint, pickInfo: pi }; - } - - /** - * Извлечь target {kind, ref} из mesh (proxy/прим/модель). - * Используется при клике/touch в Play. - */ - _meshToTarget(mesh) { - if (!mesh || !mesh.metadata) return null; - const md = mesh.metadata; - if (md.isBlock) { - return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; - } - if (md.isModel) return { kind: 'model', id: md.instanceId }; - if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; - return null; - } - - /** - * Детекция касания игроком объектов с target-скриптами. - * Для каждого target-скрипта проверяем AABB-overlap с игроком. - * Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока - * игрок не выйдет из объекта и не вернётся, повторно touch не вызывается. - */ - _detectTouchEvents() { - const rt = this.gameRuntime; - if (!rt || !this.player?._pos) return; - const scripts = this._scripts || []; - if (scripts.length === 0) return; - // Кэш «контакта»: scriptId → true если сейчас касается - if (!this._touchState) this._touchState = new Map(); - const seen = new Set(); - const px = this.player._pos.x; - const py = this.player._pos.y; // центр капсулы - const pz = this.player._pos.z; - const phw = this.player.HALF_W ?? 0.3; - const phh = this.player.HALF_H ?? 0.9; - const phd = this.player.HALF_D ?? 0.3; - - // EPS — допуск касания. Когда игрок СТОИТ на объекте сверху, - // низ его капсулы строго совпадает с верхом объекта (зазор 0), - // и строгое сравнение AABB даёт «не пересекаются». Расширяем - // зону на EPS, чтобы «стоит на объекте/вплотную» = касание. - // Без этого onTouch финиша/плитки не срабатывает (игрок встал). - const EPS = 0.25; - - // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) - for (const s of scripts) { - if (!s.target) continue; - const key = 's:' + s.id; - seen.add(key); - const aabb = this._targetAABB(s.target); - if (!aabb) continue; - const overlap = - px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && - py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && - pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - rt.routeEvent(s.target, 'touch', {}); - rt.routeGlobalEvent('playerTouch', { target: s.target }); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - rt.routeEvent(s.target, 'untouch', {}); - } - } - - // 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта — - // шлём глобальное playerTouch с target. Это позволяет писать - // логику чек-поинтов в одном глобальном скрипте без скриптов на каждом - // триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами. - // Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned): - // для них тоже шлём playerTouch, чтобы игры «поймай объект» - // могли ловить падающие кубы через game.onPlayerTouch. - const prims = this.primitiveManager?.instances; - if (prims && prims.size > 0) { - for (const data of prims.values()) { - const isTrigger = data?.type === 'trigger'; - const isSpawned = data?._scriptSpawned === true; - if (!isTrigger && !isSpawned) continue; - const id = data.id; - // Если на этот примитив УЖЕ повешен target-скрипт — он - // обработан в блоке выше, чтобы не дублировать события. - const hasScript = scripts.some(s => - s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id - ); - if (hasScript) continue; - const key = 'p:' + id; - seen.add(key); - const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; - const overlap = - px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS && - py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS && - pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - // target — строка-ref 'primitive:': её можно - // передать в game.scene.delete и сравнивать. - rt.routeGlobalEvent('playerTouch', { - target: 'primitive:' + id, - }); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - } - } - } - - // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через - // findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта - // и не триггеры — например цели туториала. Событие адресное (по ref). - const watched = rt._watchedTouchRefs; - if (watched && watched.size > 0) { - for (const ref of watched) { - const target = this._refToTarget(ref); - if (!target) continue; - const aabb = this._targetAABB(target); - if (!aabb) continue; - const key = 'w:' + ref; - seen.add(key); - const overlap = - px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && - py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && - pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - rt.routeInstEvent(ref, 'instTouch', {}); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - rt.routeInstEvent(ref, 'instUntouch', {}); - } - } - } - - // Чистим устаревшие записи (удалённые скрипты/триггеры) - for (const id of this._touchState.keys()) { - if (!seen.has(id)) this._touchState.delete(id); - } - } - - /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ - _refToTarget(ref) { - if (typeof ref !== 'string') return null; - const colon = ref.indexOf(':'); - if (colon < 0) return null; - const kind = ref.slice(0, colon); - const rest = ref.slice(colon + 1); - if (kind === 'primitive') { - const id = this.gameRuntime?._resolvePrimitiveId - ? this.gameRuntime._resolvePrimitiveId(rest) - : (Number.isFinite(Number(rest)) ? Number(rest) : rest); - return { kind: 'primitive', id }; - } - if (kind === 'model') { - const n = Number(rest); - return { kind: 'model', id: Number.isFinite(n) ? n : rest }; - } - return null; - } - - /** Получить мировой AABB target-объекта (для touch-детекции). */ - _targetAABB(target) { - if (!target) return null; - try { - if (target.kind === 'block') { - const r = target.ref || target; - return { - minX: r.x - 0.5, maxX: r.x + 0.5, - minY: r.y, maxY: r.y + 1, - minZ: r.z - 0.5, maxZ: r.z + 0.5, - }; - } - if (target.kind === 'model') { - const id = target.id ?? target.ref; - return this.modelManager?.getInstanceAABB?.(id) || null; - } - if (target.kind === 'primitive') { - const id = target.id ?? target.ref; - const data = this.primitiveManager?.instances?.get(id); - if (!data) return null; - const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; - return { - minX: data.x - hx, maxX: data.x + hx, - minY: data.y - hy, maxY: data.y + hy, - minZ: data.z - hz, maxZ: data.z + hz, - }; - } - } catch (e) { /* ignore */ } - return null; - } - - /** - * Обработка клика в Play-режиме. - * Делает forward-pick и роутит click-событие: - * - в self-обработчики скриптов (routeEvent с target) - * - в глобальные обработчики (game.onClick) с event.target - */ - _handlePlayClick(clickX, clickY) { - if (!this._isPlaying) return; - - // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. - // Используем forward-вектор игрока (XZ-плоскость) — куда смотрит, - // туда и выстрел. На сервере дальше идёт raycast по другим игрокам. - if (this._mpSync && this.player?._pos) { - try { - const yaw = this.player._yaw || 0; - // forward в плоскости XZ: yaw=0 — смотрим в +Z - const dirX = Math.sin(yaw); - const dirZ = Math.cos(yaw); - this._mpSync.sendShoot( - this.player._pos.x, - this.player._pos.z, - dirX, dirZ, - ); - } catch (e) { /* room closed / mpSync disposed */ } - } - - 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 target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; - const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; - // 1) Self-onClick — только если target есть - if (target) { - this.gameRuntime.routeEvent(target, 'click', { point }); - } - // 2) Глобальный onClick — всегда (даже если попали в пустоту) - this.gameRuntime.routeGlobalEvent('click', { point, target }); - // 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие. - // Для game.player.onToolUse(fn) из скриптов (Фаза 4.2). - try { - const active = this.inventory?.getActive?.(); - if (active) { - this.gameRuntime.routeGlobalEvent('toolUse', { - tool: { - kind: active.kind, - modelTypeId: active.modelTypeId, - name: active.name, - }, - point, target, - }); - } - } catch (e) { /* ignore */ } - } - - /** - * Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer - * после joinOrCreate. При null — отключаем мультиплеер. - */ - /** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры. - * Должно вызываться ДО enterPlayMode, иначе PlayerController создастся - * с дефолтным mouse/keyboard-управлением. */ - setTouchMode(enabled) { - this._touchMode = !!enabled; - // Если уже в Play и есть player — пробрасываем - if (this.player && typeof this.player.setTouchMode === 'function') { - this.player.setTouchMode(this._touchMode); - } - // На тач-устройствах (= мобила/планшет) включаем low-perf-режим - // автоматически: уменьшаем разрешение рендера, отключаем тени, - // увеличиваем dt физики. Это даёт ×2-3 прирост FPS. - if (this._touchMode) this.applyLowPerfMode(); - } - - /** - * Включить «лёгкий» режим рендера для слабых устройств / мобилок. - * Можно вызвать вручную и на десктопе если FPS проседает. - * - * Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно: - * 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию). - * Это даёт х2-х9 буст FPS без видимой потери качества — глаз - * телефонного экрана не различает разницу между DPR=2 и DPR=1. - * В отличие от scalingLevel=2 (рендер в половину родного), здесь - * текстуры остаются чёткими — рендерим в реальное число css-пикселей. - * 2. Отключаем тени — ShadowGenerator самый дорогой эффект. - * 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move. - * 4. maxZ=200 — урезаем дальность рендера. - * - * НЕ делаем (после теста — слишком ухудшало картинку и плавность): - * - hardwareScalingLevel > 1 (давало пикселизацию текстур) - * - FPS-cap 30 через frame-skip (давало дёрганье движения) - * - ZombieManager тик через кадр (тоже дёрганье) - */ - applyLowPerfMode() { - if (this._lowPerfApplied) return; - this._lowPerfApplied = true; - // НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение - // экрана (включая DPR). На современных телефонах GPU справляется, - // а текстуры и текст ника остаются чёткими. Прирост FPS даём за - // счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера. - // Тени — выключаем - try { - if (this._shadowGenerator) { - this._shadowGenerator.dispose(); - this._shadowGenerator = null; - } - this._shadowQuality = 'off'; - } catch (e) { /* ignore */ } - // Скип pointer-move picking — каждый кадр не делаем raycast от мыши - try { this.scene.skipPointerMovePicking = true; } catch (e) {} - // НЕ включаем blockMaterialDirtyMechanism — он ломает свойства - // материалов трейсеров/дебриса (создаются после старта, шейдер не - // пересчитывается, emissiveColor/alpha/disableLighting не применяются). - try { - if (this.scene) { - this.scene.autoClear = true; - this.scene.autoClearDepthAndStencil = true; - } - } catch (e) {} - // Уменьшаем дальность рендера если камера далеко смотрит - try { - if (this.camera && this.camera.maxZ) { - this.camera.maxZ = Math.min(this.camera.maxZ, 200); - } - } catch (e) {} - // FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет. - this._lowPerfFrameSkip = false; - // eslint-disable-next-line no-console - console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)'); - } - - setMultiplayerSync(sync) { - this._mpSync = sync; - // Сразу шлём текущее активное оружие, чтобы remote-клиенты - // увидели его в руке модели сразу после нашего onAdd. - if (sync) { - try { - const active = this.inventory?.getActive?.(); - const modelId = (active && active.kind === 'weapon') - ? (active.modelTypeId || '') - : ''; - sync.sendWeapon(modelId); - } catch (e) { /* ignore */ } - } - } - - _pickFromMouse() { - // 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п. - // Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ - // в этом проходе (он бы вернул thin instance с потерянным индексом - // в новых версиях Babylon — старая боль с выделением и постановкой). - const pi = this.scene.pick( - this.scene.pointerX, - this.scene.pointerY, - (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - if (mesh.metadata?._isBlockProto) return false; // ⬅ важно! - return true; - } - ); - - // 2) Отдельный пик блоков через свой raycast по AABB-сетке. - // Гораздо надёжнее thin-instance pick'а: даём гарантированный - // proxy + нормаль грани попадания. - const blockHit = this._pickBlockManually(); - - // Выбираем ближайший: либо стандартный pick, либо блок. - if (pi && pi.hit && blockHit) { - // Сравниваем по дистанции от камеры - const cam = this.scene.activeCamera?.position; - if (cam) { - const d1Sq = (pi.pickedPoint.x - cam.x) ** 2 - + (pi.pickedPoint.y - cam.y) ** 2 - + (pi.pickedPoint.z - cam.z) ** 2; - const d2Sq = (blockHit.point.x - cam.x) ** 2 - + (blockHit.point.y - cam.y) ** 2 - + (blockHit.point.z - cam.z) ** 2; - if (d2Sq < d1Sq) { - return blockHit; - } - } - return { - mesh: pi.pickedMesh, - point: pi.pickedPoint, - normal: pi.getNormal(true), - pickInfo: pi, - }; - } - if (blockHit) return blockHit; - if (pi && pi.hit) { - return { - mesh: pi.pickedMesh, - point: pi.pickedPoint, - normal: pi.getNormal(true), - pickInfo: pi, - }; - } - return null; - } - - /** - * Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит - * по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks? - * Возвращает { mesh: proxy, point, normal } или null. - * - * Используется DDA (digital differential analyzer) — самый быстрый алгоритм - * для voxel-raycast. - */ - _pickBlockManually() { - if (!this.blockManager || !this.scene.activeCamera) return null; - // Получаем ray из курсора - const camera = this.scene.activeCamera; - const ray = this.scene.createPickingRay( - this.scene.pointerX, this.scene.pointerY, null, camera - ); - const origin = ray.origin; - const dir = ray.direction; - - // DDA для voxel-сетки. - // Стартуем с клетки в которой находится origin - let x = Math.round(origin.x); - let y = Math.floor(origin.y); - let z = Math.round(origin.z); - - // Шаги по каждой оси - const stepX = dir.x > 0 ? 1 : -1; - const stepY = dir.y > 0 ? 1 : -1; - const stepZ = dir.z > 0 ? 1 : -1; - - // Длина шага вдоль луча для перехода на следующую клетку - const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity; - const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity; - const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity; - - // Расстояние до первой границы клетки - // Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5 - const nextBoundaryX = x + 0.5 * stepX; - const nextBoundaryY = stepY > 0 ? (y + 1) : y; - const nextBoundaryZ = z + 0.5 * stepZ; - - let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity; - let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity; - let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity; - - const MAX_STEPS = 200; // максимум 200 клеток по лучу - const MAX_DIST = 100; // и не дальше 100м - - // Какая ось пересечена последней (для вычисления нормали) - let lastAxis = -1; - - for (let i = 0; i < MAX_STEPS; i++) { - // Проверяем клетку (x, y, z) - if (y >= 0 && y < 200) { - const key = `${x},${y},${z}`; - const proxy = this.blockManager.blocks.get(key); - if (proxy && proxy.metadata?.canCollide !== false) { - // Нашли! Вычисляем точку контакта и нормаль. - let t; - let nx = 0, ny = 0, nz = 0; - if (lastAxis === 0) { - // Зашли через X-грань - t = tMaxX - tDeltaX; - nx = -stepX; - } else if (lastAxis === 1) { - t = tMaxY - tDeltaY; - ny = -stepY; - } else if (lastAxis === 2) { - t = tMaxZ - tDeltaZ; - nz = -stepZ; - } else { - // Стартуем уже внутри клетки — нормаль вверх по умолчанию - t = 0; - ny = 1; - } - if (t > MAX_DIST) return null; - const point = { - x: origin.x + dir.x * t, - y: origin.y + dir.y * t, - z: origin.z + dir.z * t, - }; - return { - mesh: proxy, - point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } }, - normal: { x: nx, y: ny, z: nz }, - pickInfo: null, - }; - } - } - // Шаг по ближайшей оси - if (tMaxX < tMaxY && tMaxX < tMaxZ) { - if (tMaxX > MAX_DIST) return null; - x += stepX; - tMaxX += tDeltaX; - lastAxis = 0; - } else if (tMaxY < tMaxZ) { - if (tMaxY > MAX_DIST) return null; - y += stepY; - tMaxY += tDeltaY; - lastAxis = 1; - } else { - if (tMaxZ > MAX_DIST) return null; - z += stepZ; - tMaxZ += tDeltaZ; - lastAxis = 2; - } - } - return null; - } - - - /** - * Обновить позицию ghost-блока под курсором. - * Вызывается каждый кадр когда tool='block'. - */ - _updateGhostPosition() { - if (!this._ghostMesh) return; - if (this._isPlaying) { - this._ghostMesh.setEnabled(false); - return; - } - if (this._activeTool !== 'block' && this._activeTool !== 'model') { - this._ghostMesh.setEnabled(false); - return; - } - const pick = this._pickFromMouse(); - if (!pick) { - this._ghostMesh.setEnabled(false); - return; - } - const target = this._computePlacementCell(pick); - if (!target) { - this._ghostMesh.setEnabled(false); - return; - } - // Не показываем ghost если в этой клетке уже блок (только для tool=block) - if (this._activeTool === 'block' && - this.blockManager?.hasBlock(target.x, target.y, target.z)) { - this._ghostMesh.setEnabled(false); - return; - } - this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z); - // Для модели — отображаем угол поворота через rotation (визуальная подсказка) - if (this._activeTool === 'model') { - this._ghostMesh.rotation.y = this._ghostRotationY; - } else { - this._ghostMesh.rotation.y = 0; - } - this._ghostMesh.setEnabled(true); - } - - /** - * Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок. - * Координаты — это нижний-передний-левый угол клетки (блок занимает - * (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)). - * - * Попали в блок: новая клетка = соседняя по нормали грани. - * Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)). - * - * Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка - * целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5). - * Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление - * без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно. - */ - _computePlacementCell(pick) { - const p = pick.point; - const n = pick.normal || new Vector3(0, 1, 0); - const mesh = pick.mesh; - - if (mesh?.metadata?.isBlock) { - // Соседняя клетка по нормали грани, в которую попали - const m = mesh.metadata; - const nx = Math.round(n.x); - const ny = Math.round(n.y); - const nz = Math.round(n.z); - const cell = { - x: m.gridX + nx, - y: m.gridY + ny, - z: m.gridZ + nz, - }; - if (cell.y < 0) return null; - return cell; - } - - // Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain). - // У этих мешей нет metadata.isBlock, но есть свои метки. Берём - // РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности - // там, куда кликнули. Без этого модель вставала на y=0 (baseplate). - const md = mesh?.metadata; - const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain); - if (isTerrain) { - return { - x: Math.round(p.x), - y: p.y, // реальная высота поверхности под курсором - z: Math.round(p.z), - }; - } - - // Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет - // центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5) - // до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX. - const x = Math.round(p.x); - const z = Math.round(p.z); - // Если попали в верхнюю грань пола → ставим на y=0. - // Если попали под низ пола (камера ниже сцены) → не ставим. - if (n.y < 0.5) return null; - return { x, y: 0, z }; - } - - /** - * Обработать клик мыши (вызывается из mouseup если это был клик, не drag). - * tool: 'block' / 'model' / 'erase' / 'select'. - */ - _handleEditorClick(shiftKey, ctrlKey = false) { - if (this._isPlaying) return; - if (!this.blockManager) return; - const pick = this._pickFromMouse(); - if (!pick) { - if (this._activeTool === 'select' && !ctrlKey) { - this.selection?.clear(); - } - return; - } - - const tool = shiftKey ? 'erase' : this._activeTool; - - if (tool === 'select') { - if (this.selection) { - // Для надёжности: если pick.mesh почему-то остался прото-мешем - // (без metadata.isBlock), пробуем разрезолвить через - // findProxyByPickInfo ещё раз. - let selectMesh = pick.mesh; - if (selectMesh?.metadata?._isBlockProto && this.blockManager) { - const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo); - if (proxy) selectMesh = proxy; - } - if (ctrlKey) { - this.selection.toggleMeshSelection(selectMesh); - } else { - this.selection.selectByMesh(selectMesh); - } - } - } else if (tool === 'block') { - const target = this._computePlacementCell(pick); - if (!target) return; - // Блоки живут в целочисленной сетке. Если кликнули по террейну, - // _computePlacementCell вернёт нецелый y (реальная высота - // поверхности) — округляем, чтобы блок встал ровно в клетку. - const by = Math.round(target.y); - const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType); - this._lastPlacedKey = `${target.x},${by},${target.z}`; - // Авто-выделение поставленного блока. Тени уже работают через proto-меш - // (зарегистрирован в refreshAllShadows и обновляется автоматически). - if (mesh) { - this.selection?.selectBlockAt(target.x, by, target.z); - if (this._onPostPlace) this._onPostPlace(); - } - } else if (tool === 'model') { - if (!this._activeModelType) return; - const cell = this._computePlacementCell(pick); - if (!cell) return; - // Пользовательская voxel-модель (id 'user:') — отдельный путь. - if (typeof this._activeModelType === 'string' - && this._activeModelType.startsWith(USER_MODEL_PREFIX)) { - this.userModelManager.addInstance( - this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY, - { currentUserId: this._currentUserId || null }, - ).then(instId => { - if (instId != null) { - const data = this.userModelManager.instances.get(instId); - if (data?.meshes) { - for (const m of data.meshes) this.addShadowCaster(m); - } - // Регистрируем коллайдер - this._syncUserModelColliders(); - if (this._onPostPlace) this._onPostPlace(); - try { this._onSceneChange?.(); } catch (e) {} - // Инкремент uses_count — fire-and-forget - const numericId = parseUserModelId(this._activeModelType); - if (numericId != null && this._userModelsApi?.incrementModelUses) { - this._userModelsApi.incrementModelUses(numericId) - .catch(() => {}); - } - } - }); - return; - } - // addInstance модели — async, ждём id и выделяем - Promise.resolve(this.modelManager.addInstance( - this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY - )).then(instId => { - if (instId != null) { - const data = this.modelManager.instances.get(instId); - if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') { - // rootMesh — TransformNode, пропускаем его и берём только меши - for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm); - } - this.selection?.selectModelByInstanceId(instId); - if (this._onPostPlace) this._onPostPlace(); - } - }); - } else if (tool === 'primitive') { - if (!this._activePrimitiveType) return; - const cell = this._computePlacementCell(pick); - if (!cell) return; - // Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке. - // Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight. - const def = getPrimitiveType(this._activePrimitiveType); - const halfH = (def.defaultScale.y) / 2; - const newId = this.primitiveManager.addInstance(this._activePrimitiveType, { - x: cell.x, y: cell.y + halfH, z: cell.z, - }); - // Авто-выделение поставленного примитива - if (newId != null) { - const data = this.primitiveManager.instances.get(newId); - if (data?.mesh) this.addShadowCaster(data.mesh); - this.selection?.selectPrimitiveById(newId); - if (this._onPostPlace) this._onPostPlace(); - } - } else if (tool === 'erase') { - if (pick.mesh?.metadata?.isBlock) { - this.blockManager.removeBlockByMesh(pick.mesh); - } else if (pick.mesh?.metadata?.isModel) { - this.modelManager.removeInstanceByMesh(pick.mesh); - } else if (pick.mesh?.metadata?.isPrimitive) { - this.primitiveManager.removeInstanceByMesh(pick.mesh); - } - } - } - - /** - * Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ - * удерживается и активен tool=block/erase. - * - * Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем - * плоскость первого блока (X/Y/Z в зависимости от грани попадания): - * - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y) - * - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X) - * - и т.д. - * - * isFirst=true — это первый клик drag'а, запоминаем ось фиксации. - */ - _dragPlaceTick(shiftKey, isFirst = false) { - if (this._isPlaying || !this.blockManager) return; - const tool = shiftKey ? 'erase' : this._activeTool; - if (tool !== 'block' && tool !== 'erase') return; - - const pick = this._pickFromMouse(); - if (!pick) return; - - if (tool === 'block') { - const target = this._computePlacementCell(pick); - if (!target) return; - - // Первый клик — запоминаем ось фиксации по нормали попадания - if (isFirst) { - const n = pick.normal; - if (Math.abs(n.y) > 0.5) { - this._dragLockAxis = 'y'; - this._dragLockValue = target.y; - } else if (Math.abs(n.x) > 0.5) { - this._dragLockAxis = 'x'; - this._dragLockValue = target.x; - } else if (Math.abs(n.z) > 0.5) { - this._dragLockAxis = 'z'; - this._dragLockValue = target.z; - } else { - this._dragLockAxis = null; - } - } else if (this._dragLockAxis) { - // На последующих движениях — переопределяем target в плоскости - if (target[this._dragLockAxis] !== this._dragLockValue) { - // Курсор ушёл с зафиксированной плоскости. Пересчитываем - // через raycast на полу/блоке, но с принудительной координатой. - target[this._dragLockAxis] = this._dragLockValue; - } - } - - const key = `${target.x},${target.y},${target.z}`; - if (key === this._lastPlacedKey) return; - if (this.blockManager.hasBlock(target.x, target.y, target.z)) return; - this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType); - this._lastPlacedKey = key; - } else if (tool === 'erase') { - if (pick.mesh?.metadata?.isBlock) { - const m = pick.mesh.metadata; - const key = `${m.gridX},${m.gridY},${m.gridZ}`; - if (key === this._lastPlacedKey) return; - this.blockManager.removeBlockByMesh(pick.mesh); - this._lastPlacedKey = key; - } - } - } - - /** - * Обновить гизмо под текущее выделение. - */ - _updateGizmoForSelection(sel) { - if (!this._gizmo) return; - if (!sel) { - this._gizmo.attachTo(null); - return; - } - if (sel.type === 'block') { - this._gizmo.attachTo(sel.mesh); - } else if (sel.type === 'model' || sel.type === 'spawn' - || sel.type === 'userModel') { - this._gizmo.attachTo(sel.rootMesh); - } else if (sel.type === 'primitive') { - this._gizmo.attachTo(sel.mesh); - } - // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) - // гарантированно пересоздалась поверх нового attached-mesh. - // Без этого гизмо иногда оказывается привязанным к старому или null - // объекту и стрелки становятся «неактивными». - this._gizmo.refreshMode(); - } - - /** - * Гизмо манипулировал объектом — синхронизируем через SelectionManager. - * Тип операции (move/rotate/scale) определяется по режиму гизмо. - */ - _onGizmoDragEnd() { - if (!this.selection || !this._gizmo) return; - const sel = this.selection.getSelection(); - if (!sel) return; - const mode = this._gizmo.getMode(); - - if (sel.type === 'block') { - if (mode === 'move') { - // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) - const newX = Math.round(sel.mesh.position.x); - const newY = Math.round(sel.mesh.position.y - 0.5); - const newZ = Math.round(sel.mesh.position.z); - if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) { - sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); - return; - } - if (this.blockManager.hasBlock(newX, newY, newZ)) { - sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); - return; - } - this.selection.moveSelectedBlock(newX, newY, newZ); - } - // Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены). - // Если пользователь дёрнул rotate/scale — игнорируем. - } else if (sel.type === 'model') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - this.selection.rotateSelectedModel(root.rotation.y); - } else if (mode === 'scale') { - // Берём средний масштаб (для равномерного скейла) - const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; - this.selection.scaleSelectedModel(avg); - } - } else if (sel.type === 'userModel') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - this.selection.rotateSelectedUserModel(root.rotation.y); - } else if (mode === 'scale') { - const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; - this.selection.scaleSelectedUserModel(avg); - } - } else if (sel.type === 'spawn') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z); - } - } else if (sel.type === 'primitive') { - const root = sel.mesh; - if (mode === 'move') { - this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - // Сохраняем поворот в data → попадёт в serialize при save. - this.primitiveManager?.updateInstance(sel.id, { - rotationX: root.rotation.x, - rotationY: root.rotation.y, - rotationZ: root.rotation.z, - }); - } else if (mode === 'scale') { - // Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится). - const newSx = sel.sx * root.scaling.x; - const newSy = sel.sy * root.scaling.y; - const newSz = sel.sz * root.scaling.z; - root.scaling.set(1, 1, 1); - this.selection.resizeSelectedPrimitive(newSx, newSy, newSz); - // resizeSelectedPrimitive уже обновил sel.mesh на новый и - // вызвал _highlightMesh. Перепривязываем гизмо к новому mesh. - const updatedSel = this.selection.getSelection(); - if (updatedSel?.mesh) { - this._gizmo.attachTo(updatedSel.mesh); - } - } - } - } - - /** Публичный сеттер: переключить инструмент извне (из React-компонента). */ - setActiveTool(toolName) { - this._activeTool = toolName; - if (this._ghostMesh) { - this._ghostMesh.setEnabled(toolName === 'block'); - } - // Preview-кисть террейна. Если меш ещё не создан — создадим лениво - // при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect). - if (toolName === 'terrain') { - if (!this._terrainBrushPreview && this._terrainBrush) { - this._updateTerrainBrushPreview(); - } - if (this._terrainBrushPreview) { - this._terrainBrushPreview.setEnabled(true); - } - } else if (this._terrainBrushPreview) { - this._terrainBrushPreview.setEnabled(false); - } - } - - /** - * Обновить состояние кисти ландшафта из TerrainPanel. - * Принимает частичный объект — то что не задано, не меняется. - */ - setTerrainBrush(patch) { - if (!this._terrainBrush) return; - const prevTool = this._terrainBrush.tool; - Object.assign(this._terrainBrush, patch || {}); - this._updateTerrainBrushPreview(); - // Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций, - // при уходе с инструмента — выключаем и снимаем подсветку. - const nowPick = this._terrainBrush.tool === 'pickDeco'; - const wasPick = prevTool === 'pickDeco'; - if (nowPick !== wasPick) { - if (this._smoothDecoManager?.setPickingEnabled) { - this._smoothDecoManager.setPickingEnabled(nowPick); - } - if (!nowPick) this._clearDecoSelection(); - } - } - - /** Снять подсветку выбранной декорации (маркер-сфера). */ - _clearDecoSelection() { - if (this._decoSelMarker) { - try { this._decoSelMarker.dispose(); } catch (e) {} - this._decoSelMarker = null; - } - this._decoSelection = null; - } - - /** - * Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям, - * подсветка выбранного дерева/куста маркером. Удаление — по Del - * (обрабатывается в _deleteSelectedDeco). - */ - _pickDecoTick() { - const dm = this._smoothDecoManager; - if (!dm) return; - const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, - (m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_')); - if (!pick || !pick.hit || !pick.pickedMesh) { - this._clearDecoSelection(); - return; - } - const thinIdx = pick.thinInstanceIndex; - const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx); - if (!found) { - this._clearDecoSelection(); - return; - } - this._decoSelection = found; - // Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко. - if (this._decoSelMarker) { - try { this._decoSelMarker.dispose(); } catch (e) {} - } - const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene); - const mat = new StandardMaterial('__decoSelMarkerMat', this.scene); - mat.emissiveColor = new Color3(1, 0.85, 0.1); - mat.alpha = 0.35; - mat.disableLighting = true; - marker.material = mat; - marker.isPickable = false; - marker.position.set(found.x, found.y + 2, found.z); - this._decoSelMarker = marker; - console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`); - } - - /** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */ - _deleteSelectedDeco() { - if (!this._decoSelection || !this._smoothDecoManager) return false; - const { decoKey, fullIndex } = this._decoSelection; - const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex); - if (ok) { - // Пересинхронизировать tree-collider'ы (вдруг удалили дерево) - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - this._clearDecoSelection(); - try { this._onSceneChange?.(); } catch (e) {} - } - return ok; - } - - /** - * Обновить geometry preview-меша кисти (полупрозрачная сфера/куб - * по форме кисти, размер 2*radius). Цвет берётся из текущего материала. - */ - _updateTerrainBrushPreview() { - if (!this._terrainBrush) return; - const { brushSize, shape, material } = this._terrainBrush; - const r = Math.max(1, brushSize); - // Берём цвет напрямую из палитры — стабильный hex, не зависит от того - // загружена ли уже текстура. Превью кисти просто тонируется в основной - // цвет выбранного материала. - // eslint-disable-next-line global-require - const { Color3 } = require('@babylonjs/core'); - let matCol = null; - try { - const def = TERRAIN_MATERIAL_DEFS?.[material]; - if (def?.color) matCol = Color3.FromHexString(def.color); - } catch (e) {} - - // Удаляем старый preview если форма/размер/режим изменились - if (this._terrainBrushPreview) { - const md = this._terrainBrushPreview.metadata || {}; - const curMode = this._terrainBrush?.terrainMode || 'voxel'; - if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) { - try { this._terrainBrushPreview.dispose(); } catch (e) {} - this._terrainBrushPreview = null; - } - } - - if (!this._terrainBrushPreview) { - // eslint-disable-next-line global-require - const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); - // Размер кисти в МИРОВЫХ единицах: - // voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр) - // smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2 - const isSmooth = this._terrainBrush?.terrainMode === 'smooth'; - const worldDiameter = isSmooth - ? r * 2 - : (r * 2 + 1) * TERRAIN_VOXEL_SIZE; - let mesh; - if (shape === 'cube') { - mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene); - } else if (shape === 'cylinder') { - mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene); - } else { - mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene); - } - const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene); - mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3); - mat.diffuseColor = new Color3(0, 0, 0); - mat.alpha = 0.22; - mat.disableLighting = false; - mat.backFaceCulling = false; - mesh.material = mat; - mesh.isPickable = false; - mesh.metadata = { - _isTerrainBrushPreview: true, - shape, radius: r, - terrainMode: this._terrainBrush?.terrainMode || 'voxel', - _baseAlpha: 0.22, - _activeAlpha: 0.45, - }; - mesh.setEnabled(this._activeTool === 'terrain'); - this._terrainBrushPreview = mesh; - } else { - // Обновляем цвет - if (matCol) { - try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {} - } - } - } - - /** - * Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в - * voxel-координатах (целые) или null. - * - * Логика: - * 1. Если попали по существующему voxel'у — берём клетку или соседнюю - * по нормали (в зависимости от инструмента). - * 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт - * площадку «где начнётся ландшафт». - */ - _pickTerrainCell(forNewVoxel) { - const pi = this.scene.pick( - this.scene.pointerX, - this.scene.pointerY, - (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh === this._terrainBrushPreview) return false; - if (mesh.metadata?._isBlockProto) return false; - if (mesh.metadata?._isTerrainProto) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - return true; - } - ); - - // Сначала — попытка raycast по существующему террейну (DDA) - const camera = this.scene.activeCamera; - const ray = this.scene.createPickingRay( - this.scene.pointerX, - this.scene.pointerY, - null, - camera, - false, - ); - const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200); - if (tHit) { - if (forNewVoxel) { - return { - x: tHit.cell.x + tHit.normal.x, - y: tHit.cell.y + tHit.normal.y, - z: tHit.cell.z + tHit.normal.z, - }; - } - return tHit.cell; - } - - // Иначе — попадание по обычным мешам (пол/блок). Делим мировые - // координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс. - if (pi?.hit && pi.pickedPoint) { - const p = pi.pickedPoint; - const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 }; - // Идём на 0.001 внутрь по противоположной нормали — чтобы координата - // упала в правильный voxel. - const inside = { - x: p.x - n.x * 0.001, - y: p.y - n.y * 0.001, - z: p.z - n.z * 0.001, - }; - const S = TERRAIN_VOXEL_SIZE; - const baseX = Math.floor(inside.x / S); - const baseY = Math.floor(inside.y / S); - const baseZ = Math.floor(inside.z / S); - if (forNewVoxel) { - return { - x: baseX + Math.round(n.x), - y: baseY + Math.round(n.y), - z: baseZ + Math.round(n.z), - }; - } - return { x: baseX, y: baseY, z: baseZ }; - } - - // Fallback — raycast на плоскость y=0. Преобразуем мировые - // координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE. - if (Math.abs(ray.direction.y) > 1e-4) { - const t = -ray.origin.y / ray.direction.y; - if (t > 0 && t < 200) { - const hx = ray.origin.x + ray.direction.x * t; - const hz = ray.origin.z + ray.direction.z * t; - const S = TERRAIN_VOXEL_SIZE; - const cx = Math.floor(hx / S); - const cz = Math.floor(hz / S); - // Прилипание к верху столбца: если в этом столбце уже - // есть voxel'ы террейна, используем Y верхнего + 1. - // Это убирает «прыжок» кисти когда юзер начинает рисовать - // на пол рядом с уже существующим холмом — она остаётся - // на уровне его поверхности. - let topY = -1; - if (this.terrainManager) { - const found = this.terrainManager._findTopY?.(cx, cz, 200, -50); - if (found !== null && found !== undefined) topY = found; - } - return { - x: cx, - y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0), - z: cz, - }; - } - } - return null; - } - - /** - * Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой - * ЛКМ когда activeTool === 'terrain'. - * - * shiftKey — модификатор «обратное действие» (стереть/опустить). - */ - _terrainBrushTick(shiftKey, isFirst) { - if (this._isPlaying) return; - if (this._activeTool !== 'terrain') return; - - // === Smooth-режим: редактируем DensityGrid через SmoothBrushes === - if (this._terrainBrush?.terrainMode === 'smooth') { - this._smoothBrushTick(shiftKey, isFirst); - return; - } - - if (!this.terrainManager) return; - const tool = this._terrainBrush?.tool || 'draw'; - - // === Rate-limit voxel-кисти === - // mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16 - // = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр. - // Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно - // покрывает поверхность за счёт drag по экрану. - if (!isFirst) { - const now = performance.now(); - const last = this._voxelBrushLastTick || 0; - const radius = this._terrainBrush?.brushSize || 4; - // Чем больше кисть, тем реже тики (защита от лагов). - const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120; - if (now - last < minInterval) return; - this._voxelBrushLastTick = now; - } else { - this._voxelBrushLastTick = performance.now(); - } - - // === Plant-кисти voxel-режима: размещение мини-воксельных моделей === - // plantGrass / plantFlower / plantMushroom / plantTree. - // Shift = стереть декорации в зоне. - if (tool === 'plantGrass' || tool === 'plantFlower' - || tool === 'plantMushroom' || tool === 'plantTree') { - // При новом клике сбрасываем rate-limit pos, чтобы первый клик - // в той же точке всегда срабатывал. - if (isFirst) { - this._voxelTreeLastPos = null; - this._voxelPlantLastPos = null; - } - const cell = this._pickTerrainCell(true); - if (!cell) return; - const brush = { - x: cell.x, y: cell.y, z: cell.z, - radius: this._terrainBrush.brushSize || 4, - shape: this._terrainBrush.shape || 'sphere', - strength: this._terrainBrush.strength ?? 50, - }; - if (shiftKey) { - this._eraseDecorationsInBrush(brush); - } else { - this._placeVoxelPlantsAtBrush(brush, tool); - } - return; - } - - // Для перекраски/выровнять — берём клетку с поверхности (не «над») - // Для рисования — клетку «над» (по нормали) - const wantsAdjacent = (tool === 'draw' || tool === 'sculpt'); - const cell = this._pickTerrainCell(wantsAdjacent); - if (!cell) return; - - // На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag - // не перепрыгивать на разные слои каждым движением мыши. - if (isFirst) { - this._terrainDragLockY = cell.y; - } - - const brush = { - x: cell.x, - y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth') - ? this._terrainDragLockY ?? cell.y - : cell.y, - z: cell.z, - radius: this._terrainBrush.brushSize || 4, - shape: this._terrainBrush.shape || 'sphere', - }; - const matId = this._terrainBrush.material || 'grass'; - const strength = this._terrainBrush.strength ?? 50; - - if (shiftKey) { - // Shift = обратная кисть. Для sculpt — опускает (Sculpt Down). - // Для всех остальных — стирает (voxels + декорации в зоне). - if (tool === 'sculpt') { - this.terrainManager.brushSculpt(brush, -1, matId, strength); - } else { - this.terrainManager.brushErase(brush); - this._eraseDecorationsInBrush(brush); - } - return; - } - - // === Деко-материалы → мини-воксельные модели === - // Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть - // ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw, - // sculpt (рисование) — где пользователь "красит" декорациями. - // smooth/paint/flatten — стандартный voxel-rendering. - if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) { - this._placeDecoModelsAtBrush(brush, matId); - return; - } - - // Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten), - // нельзя засыпать столбцы декорациями — fallback на 'grass'. - const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId; - - switch (tool) { - case 'draw': - this.terrainManager.brushDraw(brush, matId); - break; - case 'sculpt': - this.terrainManager.brushSculpt(brush, +1, matId, strength); - break; - case 'smooth': - // Smooth работает БЕЗ выбранного материала: засыпка идёт - // тем материалом, что уже есть у соседних solid voxels. - this.terrainManager.brushSmooth(brush, null); - break; - case 'paint': - this.terrainManager.brushPaint(brush, safeMatId); - break; - case 'flatten': - this.terrainManager.brushFlatten(brush, safeMatId); - break; - case 'erase': - // Стираем И voxels И декорации в зоне кисти. - this.terrainManager.brushErase(brush); - this._eraseDecorationsInBrush(brush); - break; - default: - break; - } - } - - /** Переключение «активного» состояния preview-меша кисти террейна. - * active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */ - _setTerrainBrushPreviewActive(active) { - const m = this._terrainBrushPreview; - if (!m || !m.material) return; - try { - const meta = m.metadata || {}; - m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22); - } catch (e) {} - } - - // ======================================================================== - // Undo / Redo для террейна - // - // Хранится стек снапшотов всего террейна (массив serialize'данных). - // Один drag-мазок кистью = один снапшот. История ограничена 30 шагами - // (чтобы не съесть RAM при больших террейнах). - // - // Использование: - // _terrainHistoryOpen() — перед началом мазка (mousedown по террейну) - // _terrainHistoryClose() — после конца мазка (mouseup): если что-то - // изменилось, фиксируем «открытый» снапшот - // undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y - // ======================================================================== - - /** - * Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на - * `modelId` из DECO_MODELS. Если material есть в этой карте — кисть - * ставит МОДЕЛЬ через DecoManager, а не плоский voxel. - */ - _decoMaterialToModels(matId) { - switch (matId) { - case 'tall_grass': - // Случайная модель травы из pool — каждый клик ставит разные - return GRASS_MODELS_POOL; - case 'flower_red': return ['poppy']; - case 'flower_blue': return ['cornflower']; - case 'flower_yellow': return ['dandelion', 'daisy']; - case 'mushroom_red': return ['fly_mushroom']; - // Эти материалы можно тоже спрятать под деко если хочется, - // но пока оставляем как voxels (они нужны для деревьев и т.п.) - // case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk': - default: return null; - } - } - - /** True если material — декорация (ставится моделью). */ - _isDecoMaterial(matId) { - return this._decoMaterialToModels(matId) !== null; - } - - /** - * Поставить мини-воксельные модели в зоне кисти (sphere/cube). - * Плотность — 30% точек grid в радиусе → штук 5-15 на клик. - */ - _placeDecoModelsAtBrush(brush, matId) { - if (!this.decoManager) return; - const models = this._decoMaterialToModels(matId); - if (!models || models.length === 0) return; - const TERRAIN_VOXEL = 0.25; - const r = brush.radius; - // brush.x/y/z в voxel-индексах террейна (cells 0.25м). - // Мировые координаты центра brush в МЕТРАХ. - const cx = (brush.x + 0.5) * TERRAIN_VOXEL; - const cz = (brush.z + 0.5) * TERRAIN_VOXEL; - // Top-surface Y: ищем верх solid voxels под brush.x,brush.z. - // Если на этой колонне нет voxel — ставим прямо на baseplate y=0. - let topVoxY = brush.y; - // count = ~ 8 случайных позиций - const COUNT = 10; - const placedKeys = new Set(); - for (let i = 0; i < COUNT; i++) { - // Случайная точка в круге radius (в voxel-units) - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r; - const vx = brush.x + Math.cos(angle) * rr; - const vz = brush.z + Math.sin(angle) * rr; - const worldX = (vx + 0.5) * TERRAIN_VOXEL; - const worldZ = (vz + 0.5) * TERRAIN_VOXEL; - // Y: top surface — используем brush.y + 1 voxel (над поверхностью) - const worldY = (topVoxY + 1) * TERRAIN_VOXEL; - // Защита от дублирования: не ставим 2 модели в одну сетку 0.5м - const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; - if (placedKeys.has(key)) continue; - placedKeys.add(key); - // Случайная модель из набора - const modelId = models[Math.floor(Math.random() * models.length)]; - const rotation = Math.random() * Math.PI * 2; - const scale = 0.9 + Math.random() * 0.3; - this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); - } - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Поставить декорации plant-кистью voxel-режима по типу инструмента. - * tool: plantGrass | plantFlower | plantMushroom | plantTree. - * Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели. - */ - _placeVoxelPlantsAtBrush(brush, tool) { - if (tool === 'plantTree') { - this._placeVoxelTreesAtBrush(brush); - return; - } - // Подбираем пул моделей по типу инструмента - let models; - let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне) - switch (tool) { - case 'plantGrass': - models = GRASS_MODELS_POOL; - countMul = 1.5; - break; - case 'plantFlower': - models = ['daisy', 'cornflower', 'poppy', 'dandelion']; - countMul = 1.0; - break; - case 'plantMushroom': - models = ['fly_mushroom', 'brown_mushroom']; - countMul = 0.5; - break; - default: - return; - } - if (!this.decoManager || !models || models.length === 0) return; - // Rate-limit между тиками: не ставим если кисть не сдвинулась. - const r = brush.radius; - const minDist = Math.max(1, r * 0.3); - const minDist2 = minDist * minDist; - if (this._voxelPlantLastPos) { - const dx = brush.x - this._voxelPlantLastPos.x; - const dz = brush.z - this._voxelPlantLastPos.z; - if (dx * dx + dz * dz < minDist2) return; - } - this._voxelPlantLastPos = { x: brush.x, z: brush.z }; - const TERRAIN_VOXEL = 0.25; - const topVoxY = brush.y; - // Кол-во точек пропорционально радиусу и типу декорации. - const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul))); - const placedKeys = new Set(); - for (let i = 0; i < COUNT; i++) { - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r; - const vx = brush.x + Math.cos(angle) * rr; - const vz = brush.z + Math.sin(angle) * rr; - const worldX = (vx + 0.5) * TERRAIN_VOXEL; - const worldZ = (vz + 0.5) * TERRAIN_VOXEL; - const worldY = (topVoxY + 1) * TERRAIN_VOXEL; - const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; - if (placedKeys.has(key)) continue; - placedKeys.add(key); - const modelId = models[Math.floor(Math.random() * models.length)]; - const rotation = Math.random() * Math.PI * 2; - const scale = 0.9 + Math.random() * 0.3; - this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); - } - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью. - * - * Логика как в smooth-режиме (`_smoothBrushTickPlant`): - * - 1 дерево за тик (а не пучок) - * - rate-limit: если кисть не сдвинулась далеко, пропускаем тик - * - случайный выбор типа: oak / birch / autumn - * - * Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм, - * который генерирует деревья в процедурном мире — толстый ствол, - * корни, ветви-зигзаги, главная крона + кроны на ветвях). - */ - _placeVoxelTreesAtBrush(brush) { - if (!this.terrainManager) return; - const tm = this.terrainManager; - - // === Rate-limit между тиками === - // Один тик = одно дерево. Если кисть не сдвинулась более чем на - // 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев - // друг на друге при удержании ЛКМ. - const r = brush.radius; - const minDist = Math.max(2, r * 0.4); - const minDist2 = minDist * minDist; - if (this._voxelTreeLastPos) { - const dx = brush.x - this._voxelTreeLastPos.x; - const dz = brush.z - this._voxelTreeLastPos.z; - if (dx * dx + dz * dz < minDist2) return; - } - this._voxelTreeLastPos = { x: brush.x, z: brush.z }; - - // === Случайная точка в круге кисти (jitter) === - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру - const tx = Math.round(brush.x + Math.cos(angle) * rr); - const tz = Math.round(brush.z + Math.sin(angle) * rr); - - // === Top-surface для этой XZ === - const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4); - const baseY = (topY === null || topY === undefined) ? brush.y : topY; - - // === Размер дерева от strength (1..100) === - // strength=10 → саженец (sizeScale=0.5) - // strength=50 → стандарт (sizeScale=1.0) - // strength=100 → большое (sizeScale=2.0) - const strength = brush.strength ?? 50; - const sizeScale = 0.5 + (strength / 100) * 1.5; - - // === Случайный тип дерева === - const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)]; - - // === Уникальный seed на каждое дерево — даёт разную форму === - const seed = (Math.random() * 0x7fffffff) | 0; - - // === Ставим voxels через batched setVoxel-fn === - // tm._addInstance не обновляет GPU buffer в batch-режиме, делаем - // один flushBatch в конце. Это превращает ~300 add'ов в один upload. - tm._beginBatch?.(); - let placed = 0; - try { - const setVoxelFn = (x, y, z, matId) => { - const k = `${x},${y},${z}`; - if (tm.voxels.has(k)) return; - tm._addInstance?.(k, x, y, z, matId); - tm.voxels.set(k, matId); - placed++; - }; - placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed); - } finally { - tm._flushBatch?.(); - } - if (placed > 0) { - try { tm._emit?.(); } catch (e) {} - try { this._onSceneChange?.(); } catch (e) {} - } - } - - /** Удалить все декорации в зоне кисти (по миру). */ - _eraseDecorationsInBrush(brush) { - if (!this.decoManager || !this.decoManager.placements) return; - const TERRAIN_VOXEL = 0.25; - const cx = (brush.x + 0.5) * TERRAIN_VOXEL; - const cz = (brush.z + 0.5) * TERRAIN_VOXEL; - const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства - const r2 = r * r; - const keep = []; - let removed = 0; - for (const p of this.decoManager.placements) { - const dx = p.x - cx; - const dz = p.z - cz; - if (dx * dx + dz * dz <= r2) { - removed++; - } else { - keep.push(p); - } - } - if (removed > 0) { - // Перезагружаем decoManager с обновлённым списком - this.decoManager.clear(); - this.decoManager.loadFromArray(keep); - try { this._onSceneChange?.(); } catch (e) {} - } - } - - _terrainHistoryEnsure() { - if (!this._terrainHistory) { - this._terrainHistory = { stack: [], cursor: -1, pending: null }; - } - return this._terrainHistory; - } - _terrainHistoryOpen() { - const tm = this.terrainManager; - if (!tm) return; - const h = this._terrainHistoryEnsure(); - // Снапшот до изменения - h.pending = tm.serialize(); - } - _terrainHistoryClose() { - const tm = this.terrainManager; - if (!tm) return; - const h = this._terrainHistoryEnsure(); - if (!h.pending) return; - const after = tm.serialize(); - // Сравниваем размером — если ничего не изменилось, не пушим - if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) { - h.pending = null; - return; - } - // Обрубаем все «впереди-курсора» (redo-стек после нового действия) - if (h.cursor < h.stack.length - 1) { - h.stack.length = h.cursor + 1; - } - h.stack.push(h.pending); - h.cursor = h.stack.length - 1; - // Ограничение 30 шагов - const MAX = 30; - while (h.stack.length > MAX) { - h.stack.shift(); - h.cursor--; - } - h.pending = null; - } - _terrainSerEqual(a, b) { - // Сравнение двух serialize-массивов. O(n) с использованием Set. - if (a.length !== b.length) return false; - const sa = new Set(); - for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`); - for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false; - return true; - } - undoTerrain() { - const tm = this.terrainManager; - if (!tm) return false; - const h = this._terrainHistoryEnsure(); - if (h.cursor < 0) return false; - // Текущий стейт в редо-позицию (cursor+1) - const current = tm.serialize(); - const target = h.stack[h.cursor]; - // Если на cursor лежит снапшот «до», нам нужно вернуться к нему. - // Cursor указывает на последнее ВОЗВРАТНОЕ состояние. - tm.loadFromArray(target); - // Записываем текущий стейт в позицию cursor+1 для возможного redo - h.stack[h.cursor + 1] = current; - h.cursor--; - return true; - } - redoTerrain() { - const tm = this.terrainManager; - if (!tm) return false; - const h = this._terrainHistoryEnsure(); - if (h.cursor + 1 >= h.stack.length - 0) return false; - const target = h.stack[h.cursor + 2]; - if (!target) return false; - tm.loadFromArray(target); - h.cursor++; - return true; - } - - // ======================================================================== - // Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать») - // - // Регион — это объёмная коробка, заданная двумя углами в voxel-индексах. - // Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле: - // первый mousedown в режиме «Выделить» — стартовый угол, drag — второй. - // Высота коробки фиксируется ±radius по Y вокруг плоскости клика. - // - // Регион используется: - // • «Заполнить» — залить регион выбранным материалом - // • «Преобразовать» — переместить все voxel'ы региона в новое место - // (плоский drag по XZ, без поворота на этапе 2). - // ======================================================================== - - /** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */ - getTerrainRegion() { return this._terrainRegion || null; } - - /** Очистить выделение и убрать визуализацию. */ - clearTerrainRegion() { - this._terrainRegion = null; - if (this._terrainRegionMesh) { - try { this._terrainRegionMesh.dispose(); } catch (e) {} - this._terrainRegionMesh = null; - } - } - - /** Обновить wireframe-визуализацию региона по this._terrainRegion. */ - _updateTerrainRegionVisual() { - const r = this._terrainRegion; - if (this._terrainRegionMesh) { - try { this._terrainRegionMesh.dispose(); } catch (e) {} - this._terrainRegionMesh = null; - } - if (!r) return; - - // eslint-disable-next-line global-require - const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); - const S = TERRAIN_VOXEL_SIZE; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - // Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно. - const sizeX = (maxX - minX + 1) * S; - const sizeY = (maxY - minY + 1) * S; - const sizeZ = (maxZ - minZ + 1) * S; - const cx = (minX + 0.5) * S + (sizeX - S) / 2; - const cy = (minY + 0.5) * S + (sizeY - S) / 2; - const cz = (minZ + 0.5) * S + (sizeZ - S) / 2; - - const mesh = MeshBuilder.CreateBox('__terrainRegion', { - width: sizeX, height: sizeY, depth: sizeZ, - }, this.scene); - mesh.position.set(cx, cy, cz); - mesh.isPickable = false; - const mat = new StandardMaterial('__terrainRegionMat', this.scene); - mat.wireframe = true; - mat.emissiveColor = new Color3(0.20, 0.55, 1.00); - mat.diffuseColor = new Color3(0, 0, 0); - mat.alpha = 0.9; - mesh.material = mat; - mesh.metadata = { _isTerrainRegion: true }; - this._terrainRegionMesh = mesh; - } - - /** Запустить выделение региона: pickStart — voxel-клетка начала. */ - _terrainBeginRegion(pickStart) { - const radius = this._terrainBrush?.brushSize || 4; - // Высота региона по умолчанию = ±radius от Y клика. При drag юзер - // может уточнить — но через первый MVP оставим фиксированной. - this._terrainRegion = { - x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z, - x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z, - }; - this._terrainRegionDragging = true; - this._updateTerrainRegionVisual(); - } - - /** Обновить второй угол региона по новой voxel-клетке. */ - _terrainUpdateRegion(pickEnd) { - if (!this._terrainRegion) return; - this._terrainRegion.x1 = pickEnd.x; - this._terrainRegion.z1 = pickEnd.z; - // Y оставляем как поставили при начале — drag по плоскости XZ - this._updateTerrainRegionVisual(); - } - - /** Завершить drag выделения. */ - _terrainEndRegion() { - this._terrainRegionDragging = false; - } - - /** - * Инициализировать пустой smooth-terrain для скульптинга с нуля. - * Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде. - * Первый клик sculpt-кистью сразу породит холм в нужном месте. - */ - _initEmptySmoothTerrain() { - if (this._robloxTerrain) { - try { this._robloxTerrain.disposeAll(); } catch (e) {} - } - this._robloxTerrain = new RobloxTerrain(this.scene); - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - const sx = 100, sy = 24, sz = 100; - const grid = new RobloxDensityGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - // Регистрируем стандартные материалы в палитре (нужно для brushes). - // Index 0 = пусто, далее по порядку для совместимости с web/Android. - for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) { - // Hack: set одной ячейки потом обнуляем, чтобы добавить в palette. - grid.set(0, 0, 0, 0, matKey); - } - // Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId - // последнего set'а. Обнуляем явно. - grid.densityData[0] = 0; - grid.matData[0] = 0; - // skipEmpty: true — НЕ добавляем 98 пустых chunks в pending, - // mesher будет работать только после первой кисти. - this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true }); - // НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать - // плоскость y=0 при первых кликах. Отключим когда появится хоть один - // solid chunk (см. updateStreaming / applyBrushAndRebuild). - console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)'); - } - - /** - * Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain, - * получает worldPosition и вызывает applyBrush в этой точке. - * - * Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt - * на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells - * (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio. - */ - _smoothBrushTick(shiftKey, isFirst) { - const tool = this._terrainBrush?.tool || 'sculpt'; - // === Инструмент «Выбрать деко» — поштучный клик-выбор декораций === - // Не модифицирует ландшафт, работает только на клик (не drag). - if (tool === 'pickDeco') { - if (isFirst) this._pickDecoTick(); - return; - } - const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid; - - // === Счётчики для диагностики === - if (!this._smoothBrushDiag) { - this._smoothBrushDiag = { - tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0, - applyResult: { built: 0, dirty0: 0 }, - }; - } - const D = this._smoothBrushDiag; - D.tickCount++; - const tickN = D.tickCount; - - // === Plane-lock + rate-limit (как в Roblox Studio) === - // При isFirst — фиксируем плоскость и позицию первого клика. - // Дальше при drag: - // 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом), - // а из зафиксированного _smoothBrushLockY → кисть работает в плоскости. - // 2) Между tick'ами требуется минимальное расстояние (0.6×radius) — - // иначе одна и та же точка → cells доходят до 255 → "какаха". - if (isFirst) { - this._smoothBrushLockY = null; - this._smoothBrushLastPos = null; - } - - // === Raycast — выбираем точку под курсором === - let hit = null; - let pickSource = ''; - if (!terrainEmpty) { - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - hit = this.scene.pickWithRay( - this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera), - pickPred, - ); - if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; } - } - if (!hit || !hit.hit || !hit.pickedPoint) { - const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, - (m) => m.name === 'editorGround'); - if (groundPick && groundPick.hit && groundPick.pickedPoint) { - hit = groundPick; - pickSource = 'ground'; - D.hitGround++; - } else { - D.hitNone++; - if (isFirst || tickN % 20 === 0) { - console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`); - } - return; - } - } - const worldPt = hit.pickedPoint; - - // === Инициализация пустого grid при первом клике sculpt/fill === - let initialized = false; - if (terrainEmpty) { - if (!isFirst) { - if (tickN <= 5 || tickN % 30 === 0) { - console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`); - } - return; - } - if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') { - console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`); - return; - } - console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`); - this._initEmptySmoothTerrain(); - initialized = true; - if (!this._robloxTerrain || !this._robloxTerrain.grid) { - console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`); - return; - } - } - - const material = this._terrainBrush?.material || 'grass'; - const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0); - // strength: slider 0..100 → реальная strength 60..400 для sculpt. - // Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells - // на краю кисти достигали threshold 128 за 40 тиков а не 128. - // Максимум 400 = мгновенно ставит cells в 255 (полная заливка). - const strengthSlider = this._terrainBrush?.strength ?? 50; - const strength = 60 + (strengthSlider / 100) * 340; - - let brushType; - if (shiftKey) { - brushType = 'sculptDown'; - } else { - switch (tool) { - case 'draw': brushType = 'sculptUp'; break; - case 'sculpt': brushType = 'sculptUp'; break; - case 'smooth': brushType = 'smooth'; break; - case 'paint': brushType = 'paint'; break; - case 'flatten': - if (isFirst) this._smoothFlattenTargetY = worldPt.y; - brushType = 'flatten'; - break; - case 'fill': brushType = 'fill'; break; - case 'erase': brushType = 'erase'; break; - // === Plant-кисти: добавление декораций === - case 'plantGrass': brushType = 'plantGrass'; break; - case 'plantFlower': brushType = 'plantFlower'; break; - case 'plantMushroom': brushType = 'plantMushroom'; break; - case 'plantTree': brushType = 'plantTree'; break; - default: brushType = 'sculptUp'; - } - } - - // === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики === - // Они НЕ модифицируют DensityGrid — добавляют thin-instance модели. - if (brushType.startsWith('plant')) { - return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst); - } - - // === Plane-lock: для sculpt-кистей фиксируем Y первого клика === - // Без этого при drag в одной XZ-точке raycast возвращает всё более - // высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ → - // рельеф летит на камеру ("какаха"). - // С lock'ом drag работает в горизонтальной плоскости фиксированной - // высоты первого клика, как в Roblox Studio. - const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown'; - let centerY = worldPt.y; - if (isSculptKind) { - if (this._smoothBrushLockY === null) { - this._smoothBrushLockY = worldPt.y; - } - centerY = this._smoothBrushLockY; - // Смещение от плоскости первого клика на radius×0.5 - // (вверх для sculptUp, вниз для sculptDown). - if (brushType === 'sculptUp') centerY += radius * 0.5; - else centerY -= radius * 0.5; - } - - // === Rate-limit: пропускаем tick если кисть не сдвинулась далеко === - // Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback - // loop в одной точке. - if (isSculptKind && !isFirst && this._smoothBrushLastPos) { - const dx = worldPt.x - this._smoothBrushLastPos.x; - const dz = worldPt.z - this._smoothBrushLastPos.z; - const minDist = radius * 0.6; - if (dx * dx + dz * dz < minDist * minDist) { - // Кисть в той же точке — пропускаем (но НЕ для isFirst). - return; - } - } - if (isSculptKind) { - this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; - } - - const params = { - center: { x: worldPt.x, y: centerY, z: worldPt.z }, - radius, - strength, - material, - targetY: this._smoothFlattenTargetY, - }; - const tApply0 = performance.now(); - const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params); - const tApply = performance.now() - tApply0; - if (built > 0) D.applyResult.built += built; - else D.applyResult.dirty0++; - - if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) { - const gridStats = this._robloxTerrain.grid - ? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}` - : 'no-grid'; - console.log( - `[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} ` - + `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) ` - + `r=${radius} mat=${material} ${gridStats} ` - + `→ built=${built} chunks in ${tApply.toFixed(0)}ms ` - + (initialized ? ' [INITIALIZED!]' : '') - + (built === 0 ? ' [NO CHANGE]' : ''), - ); - } - - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья). - * Не трогает DensityGrid — работает только с SmoothDecoManager. - * Shift = ластик (удалить декорации в радиусе). - */ - _smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) { - // Нужен SmoothDecoManager (создаём lazy при первом plant-клике) - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - this._smoothDecoManager.loadAll(); - } - // Rate-limit как в sculpt: пропускаем близкие тики - if (!isFirst && this._smoothBrushLastPos) { - const dx = worldPt.x - this._smoothBrushLastPos.x; - const dz = worldPt.z - this._smoothBrushLastPos.z; - const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие) - if (dx * dx + dz * dz < minDist * minDist) return; - } - this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; - - // Shift — ластик - if (shiftKey) { - const removed = this._smoothDecoManager.removeBrushDecoInRadius( - { x: worldPt.x, z: worldPt.z }, radius, - ); - if (removed > 0) { - console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); - // Пересинхронизировать tree-colliders в physics - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - try { this._onSceneChange?.(); } catch (e) {} - } - return; - } - - // Маппинг brushType → kind для SmoothDecoManager - const kindMap = { - plantGrass: 'grass', - plantFlower: 'flower', - plantMushroom: 'mushroom', - plantTree: 'tree', - }; - const kind = kindMap[brushType]; - if (!kind) return; - - // Количество инстансов за один тик зависит от типа. - // Трава густая, деревья редко. - const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 }; - const count = countMap[kind] || 3; - - // Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0). - const sampleSurfaceY = (x, z) => { - if (this.physics?._sampleRobloxSurface) { - const y = this.physics._sampleRobloxSurface(x, z); - if (y !== null) return y; - } - // Fallback на ground y=0 - return 0; - }; - - const result = this._smoothDecoManager.addBrushDeco({ - kind, - center: { x: worldPt.x, y: worldPt.y, z: worldPt.z }, - radius, - count, - sampleSurfaceY, - }); - const added = result.added || 0; - if (added > 0) { - console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); - // Если посадили деревья — пересинхронизировать tree-colliders - // в physics (полная переустановка через getAllTreeColliders). - if (kind === 'tree' && this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - try { this._onSceneChange?.(); } catch (e) {} - } - } - - /** Залить выделенный регион материалом. Используется инструментом - * «Заполнить» когда есть активный _terrainRegion. */ - _terrainFillRegion(matId) { - const r = this._terrainRegion; - const tm = this.terrainManager; - if (!r || !tm) return 0; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - let n = 0; - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - for (let z = minZ; z <= maxZ; z++) { - const key = `${x},${y},${z}`; - if (tm.voxels.has(key)) continue; - tm.setVoxel(x, y, z, matId); - n++; - } - } - } - return n; - } - - /** Переместить весь регион на dx/dy/dz в voxel-клетках. */ - _terrainMoveRegion(dx, dy, dz) { - const r = this._terrainRegion; - const tm = this.terrainManager; - if (!r || !tm) return 0; - if (dx === 0 && dy === 0 && dz === 0) return 0; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - // Собираем содержимое региона - const collected = []; - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - for (let z = minZ; z <= maxZ; z++) { - const m = tm.getVoxel(x, y, z); - if (!m) continue; - collected.push({ x, y, z, m }); - } - } - } - // Удаляем из старых позиций - for (const v of collected) tm.removeVoxel(v.x, v.y, v.z); - // Ставим в новые - let n = 0; - for (const v of collected) { - tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m); - n++; - } - // Сдвигаем сам регион - r.x0 += dx; r.x1 += dx; - r.y0 += dy; r.y1 += dy; - r.z0 += dz; r.z1 += dz; - this._updateTerrainRegionVisual(); - return n; - } - - /** Двигать preview-меш под курсор. Вызывается из mousemove. */ - _updateTerrainBrushPosition() { - if (this._activeTool !== 'terrain') return; - if (!this._terrainBrushPreview) return; - // === Smooth-режим: raycast по smooth-mesh, preview на surface === - if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) { - const ray = this.scene.createPickingRay( - this.scene.pointerX, this.scene.pointerY, - null, this.camera, - ); - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - const hit = this.scene.pickWithRay(ray, pickPred); - if (hit && hit.hit && hit.pickedPoint) { - this._terrainBrushPreview.position.copyFrom(hit.pickedPoint); - } - return; - } - // Voxel-режим (как было) - const cell = this._pickTerrainCell(false); - if (!cell) return; - const S = TERRAIN_VOXEL_SIZE; - this._terrainBrushPreview.position.set( - (cell.x + 0.5) * S, - (cell.y + 0.5) * S, - (cell.z + 0.5) * S, - ); - } - - /** Публичный сеттер: выбрать тип блока для постановки. */ - setActiveBlockType(blockTypeId) { - this._activeBlockType = blockTypeId; - } - - /** Публичный сеттер: выбрать тип модели для постановки. */ - setActiveModelType(modelTypeId) { - this._activeModelType = modelTypeId; - } - - /** Тип примитива для постановки (cube/sphere/...). */ - setActivePrimitiveType(typeId) { - this._activePrimitiveType = typeId; - } - - getPrimitiveCount() { - return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0; - } - - /** Количество блоков (для status bar). */ - getBlockCount() { - return this.blockManager ? this.blockManager.count() : 0; - } - - /** Количество моделей-инстансов. */ - getModelCount() { - return this.modelManager ? this.modelManager.getInstanceCount() : 0; - } - - /** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */ - setOnSelectionChange(cb) { - if (this.selection) { - // Объединяем со внутренней подпиской на gizmo - this.selection.setOnSelectionChange((sel) => { - this._updateGizmoForSelection(sel); - if (cb) cb(sel); - }); - } - } - - /** Текущее выделение (или null). */ - getSelection() { - return this.selection?.getSelection() || null; - } - - /** Выделить блок программно (например по клику в Hierarchy). */ - selectBlockAt(x, y, z) { - this.selection?.selectBlockAt(x, y, z); - } - - /** Выделить модель программно. */ - selectModelByInstanceId(id) { - this.selection?.selectModelByInstanceId(id); - } - - /** Снять выделение. */ - clearSelection() { - this.selection?.clear(); - } - - /** Удалить выделенный объект. */ - deleteSelected() { - this.selection?.deleteSelected(); - } - - /** - * Дублировать выделенный объект (Ctrl+D). - * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). - * Модель: создаёт копию со смещением +1 по X. - */ - duplicateSelected() { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - // Ищем свободную клетку рядом - const candidates = [ - [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0], - ]; - for (const [dx, dy, dz] of candidates) { - const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz; - if (ny < 0) continue; - if (!this.blockManager.hasBlock(nx, ny, nz)) { - this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); - this.selection.selectBlockAt(nx, ny, nz); - return; - } - } - } else if (sel.type === 'model') { - // Сохраняем все нужные поля выделения до того как промис завершится - // (selection может перезатереться к моменту resolve) - const typeId = sel.modelTypeId; - const sx = sel.x, sy = sel.y, sz = sel.z; - const rotY = sel.rotationY || 0; - this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) - .then(newId => { - if (newId != null) this.selection?.selectModelByInstanceId(newId); - }) - .catch(err => { - // eslint-disable-next-line no-console - console.error('[BabylonScene] duplicate model error:', err); - }); - } else if (sel.type === 'userModel') { - const typeId = sel.userModelTypeId; - const sx = sel.x, sy = sel.y, sz = sel.z; - const rotY = sel.rotationY || 0; - this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { - currentUserId: this._currentUserId || null, - }).then(newId => { - if (newId != null) this.selection?.selectUserModelByInstanceId(newId); - }).catch(err => { - console.error('[BabylonScene] duplicate user model error:', err); - }); - } else if (sel.type === 'primitive') { - const newId = this.primitiveManager.addInstance(sel.primitiveType, { - x: sel.x + 1, y: sel.y, z: sel.z, - sx: sel.sx, sy: sel.sy, sz: sel.sz, - color: sel.color, material: sel.material, - canCollide: sel.canCollide, visible: sel.visible, - anchored: sel.anchored, - // Копируем и спец-свойства: текстуру, параметры лампы/эмиттера. - textureAsset: sel.textureAsset || null, - brightness: sel.brightness, range: sel.range, effect: sel.effect, - }); - if (newId != null) this.selection.selectPrimitiveById(newId); - } - } - - /** - * Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10). - * Буфер — localStorage, поэтому переживает перезагрузку страницы - * и смену проекта (Copy/Paste между проектами). - */ - copySelected() { - const sel = this.selection?.getSelection(); - if (!sel) return; - let clip = null; - if (sel.type === 'block') { - clip = { kind: 'block', blockTypeId: sel.blockTypeId }; - } else if (sel.type === 'model') { - clip = { - kind: 'model', modelTypeId: sel.modelTypeId, - rotationY: sel.rotationY || 0, scale: sel.scale || 1, - }; - } else if (sel.type === 'userModel') { - clip = { - kind: 'userModel', userModelTypeId: sel.userModelTypeId, - rotationY: sel.rotationY || 0, - }; - } else if (sel.type === 'primitive') { - clip = { - kind: 'primitive', primitiveType: sel.primitiveType, - sx: sel.sx, sy: sel.sy, sz: sel.sz, - color: sel.color, material: sel.material, - canCollide: sel.canCollide, visible: sel.visible, - anchored: sel.anchored, - textureAsset: sel.textureAsset || null, - brightness: sel.brightness, range: sel.range, effect: sel.effect, - }; - } - if (clip) { - try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } - catch (e) { /* ignore — приватный режим / переполнение */ } - } - } - - /** - * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). - * Объект появляется у точки, куда смотрит редактор-камера. - */ - pasteFromClipboard() { - let clip; - try { - const raw = localStorage.getItem('kubikon_clipboard'); - if (!raw) return; - clip = JSON.parse(raw); - } catch (e) { return; } - if (!clip || !clip.kind) return; - // Точка вставки — перед редактор-камерой (~6м по направлению взгляда). - const cam = this.camera; - let px = 0, py = 1, pz = 0; - if (cam) { - const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null; - if (fwd) { - px = cam.position.x + fwd.x * 6; - pz = cam.position.z + fwd.z * 6; - } - } - if (clip.kind === 'block') { - const gx = Math.round(px), gz = Math.round(pz); - let gy = 0; - while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; - this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); - this.selection?.selectBlockAt(gx, gy, gz); - } else if (clip.kind === 'model') { - this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) - .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) - .catch(() => {}); - } else if (clip.kind === 'userModel') { - this.userModelManager?.addInstance( - clip.userModelTypeId, px, py, pz, clip.rotationY || 0, - { currentUserId: this._currentUserId || null }, - ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) - .catch(() => {}); - } else if (clip.kind === 'primitive') { - const id = this.primitiveManager?.addInstance(clip.primitiveType, { - x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, - sx: clip.sx, sy: clip.sy, sz: clip.sz, - color: clip.color, material: clip.material, - canCollide: clip.canCollide, visible: clip.visible, - anchored: clip.anchored, - textureAsset: clip.textureAsset || null, - brightness: clip.brightness, range: clip.range, effect: clip.effect, - }); - if (id != null) this.selection?.selectPrimitiveById(id); - } - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Поставить выделенный объект на пол (y = 0). - * Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2. - */ - alignSelectedToFloor() { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ); - } else if (sel.type === 'model') { - // Модель: rootMesh.position.y = низ модели. y=0 = низ на полу. - this.selection.moveSelectedModel(sel.x, 0, sel.z); - } else if (sel.type === 'userModel') { - this.selection.moveSelectedUserModel(sel.x, 0, sel.z); - } else if (sel.type === 'primitive') { - // Центр примитива должен быть на высоте sy/2 чтобы низ касался пола. - const halfH = (sel.sy || 1) / 2; - this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z); - } - } - - /** - * View-preset — поставить редактор-камеру в одну из стандартных позиций. - * preset: 'top' | 'front' | 'side' | 'iso' - */ - setViewPreset(preset) { - if (!this.camera) return; - const presets = { - top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху - front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди - side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку - iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия - }; - const p = presets[preset]; - if (!p) return; - this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]); - this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]); - } - - /** - * Поставить точку спавна там где сейчас смотрит редактор-камера - * (полезно для размещения «тут начинать игру»). - * spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z). - */ - setSpawnAtCamera() { - if (!this.camera) return; - const p = this.camera.position; - this._spawnPoint = { - x: Math.round(p.x), - y: Math.max(0, Math.floor(p.y) - 1), - z: Math.round(p.z), - }; - this._updateSpawnMarker(); - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** Изменить позицию выделенного (используется Inspector). */ - moveSelectedTo(x, y, z) { - if (!this.selection) return; - const sel = this.selection.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - this.selection.moveSelectedBlock(Math.round(x), Math.round(y), Math.round(z)); - } else if (sel.type === 'model') { - this.selection.moveSelectedModel(x, y, z); - } else if (sel.type === 'userModel') { - this.selection.moveSelectedUserModel(x, y, z); - } else if (sel.type === 'spawn') { - this.selection.moveSelectedSpawn(x, y, z); - } else if (sel.type === 'primitive') { - this.selection.moveSelectedPrimitive(x, y, z); - } - } - - /** Изменить размер выделенного примитива (Inspector). */ - resizeSelectedPrimitiveTo(sx, sy, sz) { - this.selection?.resizeSelectedPrimitive(sx, sy, sz); - } - - /** Изменить свойства выделенного примитива (color/material/canCollide/visible). */ - setSelectedPrimitivePropsTo(patch) { - this.selection?.setSelectedPrimitiveProps(patch); - } - - /** Повернуть выделенную модель (Y, в радианах). */ - rotateSelectedModelTo(angleRad) { - if (!this.selection) return; - const sel = this.selection.getSelection(); - if (sel?.type === 'userModel') { - this.selection.rotateSelectedUserModel(angleRad); - } else { - this.selection.rotateSelectedModel(angleRad); - } - } - - /** Изменить масштаб выделенной модели. */ - scaleSelectedModelTo(scale) { - if (!this.selection) return; - const sel = this.selection.getSelection(); - if (sel?.type === 'userModel') { - this.selection.scaleSelectedUserModel(scale); - } else { - this.selection.scaleSelectedModel(scale); - } - } - - /** Установить режим гизмо: 'select' | 'move' | 'rotate' | 'scale'. */ - setGizmoMode(mode) { - if (this._gizmo) this._gizmo.setMode(mode); - } - - /** Получить текущий режим гизмо. */ - getGizmoMode() { - return this._gizmo ? this._gizmo.getMode() : 'select'; - } - - /** Установить snap-step гизмо для перемещения (1.0 / 0.5 / 0.25 / 0=off). - * Также применяется к Inspector-вводу координат моделей. */ - setGizmoSnap(step) { - if (this._gizmo) this._gizmo.setSnap(step); - if (this.selection) this.selection.setSnapStep(step); - } - - getGizmoSnap() { - return this._gizmo ? this._gizmo.getSnap() : 0; - } - - /** Сфокусировать редактор-камеру на выделенном (двигает камеру к объекту). */ - focusOnSelection() { - const sel = this.selection?.getSelection(); - if (!sel) return; - let target; - if (sel.type === 'block') { - target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ); - } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') { - target = new Vector3(sel.x, sel.y + 0.5, sel.z); - } - if (target) this._focusOnTarget(target); - } - - /** Установить точку спавна игрока в режиме Play. */ - setSpawnPoint(x, y, z) { - this._spawnPoint = { x, y, z }; - this._updateSpawnMarker(); - } - - /** Установить тип модели персонажа (для Play). */ - setPlayerModelType(typeId) { - if (!typeId) return; - this._playerModelType = typeId; - } - - getPlayerModelType() { - return this._playerModelType; - } - - /** Идёт ли сейчас режим игры. */ - isPlaying() { - return this._isPlaying; - } - - /** - * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, - * запоминаем позицию редактор-камеры чтобы вернуть при exit. - */ - enterPlayMode() { - if (this._isPlaying) return; - this._isPlaying = true; - // Сброс состояния касаний — каждый прогон начинается «не касаясь». - if (this._touchState) this._touchState.clear(); - this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play - // По умолчанию стандартный HUD видим в Play. - // Скрипт может скрыть через game.hud.setVisible(false). - this._setStdHudVisible(true); - - // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld - // не «видит» воксели в Ray-каст и пролетает сквозь стены. - try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {} - - // Снимок редактор-камеры - this._editorCameraSnapshot = { - position: this.camera.position.clone(), - rotation: this.camera.rotation.clone(), - }; - - if (this._ghostMesh) this._ghostMesh.setEnabled(false); - this._setSpawnMarkerVisible(false); - // Триггеры — невидимые в Play, видимые в редакторе - this.primitiveManager?.setTriggersVisible(false); - - // Запоминаем исходные позиции unanchored-объектов чтобы вернуть - // их при выходе из Play (физика двигает mesh.position). - this._snapshotDynamicObjects(); - // Полный снимок примитивов и моделей — чтобы при Stop откатить - // ВСЕ изменения скриптов (удаления, цвет, видимость, повороты). - this._snapshotFullScene(); - - // Запускаем физику unanchored - this.dynamics?.start(); - - // Запускаем фоновую музыку и амбиент - this.audioManager?.start(); - - // Создаём PlayerController и стартуем - this.player = new PlayerController(this.scene, this.canvas, this.physics, this); - this.player.setModelType(this._playerModelType); - // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck - try { - this.modalManager?.attachPlayer?.(this.player); - this.modalManager?.attachAudio?.(this.audioManager); - } catch (e) {} - this.player._jumpPowerMul = this._jumpPowerMul ?? 1; - // Применяем дефолтную камеру если задана в сцене - if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { - this.player._cameraMode = this._defaultCameraMode; - } - // На тач-устройствах отключаем pointer-lock и mouse-камеру - if (this._touchMode) this.player.setTouchMode(true); - this.player.setOnExitRequest(() => { - // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). - if (this._skinShop?.open) { - this._closeSkinShop(); - return; - } - // Задача 04: если открыт модал — первый Esc закрывает его, - // второй Esc уже выходит из Play. Так юзер не теряет состояние игры - // случайно при попытке скрыть модал. - if (this.modalManager?.isOpen?.()) { - this.modalManager.close(); - return; - } - // ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox). - // Единый источник истины — _playerMenuOpen в движке. Раньше состояние - // меню держал React, а ESC слушали ДВА обработчика (движок + React) → - // гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true - // → orbit-камера по ПКМ переставала работать после закрытия меню. - // Теперь движок сам решает open/close и шлёт это в _onEscMenu(open). - if (typeof this._onEscMenu === 'function') { - if (this._playerMenuOpen) { - // Меню открыто → ESC закрывает: вернуть мышь в игру. - this._playerMenuOpen = false; - this.player?.setUiCursorMode?.(false); - this._onEscMenu(false); - } else { - // Меню закрыто → ESC открывает: освободить курсор. - this._playerMenuOpen = true; - this.player?.setUiCursorMode?.(true); - this._onEscMenu(true); - } - return; - } - // Фолбэк (если меню не подписано, напр. в студии) — старое поведение. - this.exitPlayMode(); - if (this._onPlayChange) this._onPlayChange(false); - }); - if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); - if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); - this.player.start(this._spawnPoint); - - // Запускаем пользовательские скрипты (этап 2.1). - // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, - // поэтому скрипты стартуем в следующем кадре. - this.gameRuntime = new GameRuntime(this); - try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} - // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. - // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным - // this.audioManager (AudioManager — ambient/music для всех проектов). - if (!this.gameAudioManager) { - this.gameAudioManager = new GameAudioManager(); - } - // GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты. - // Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе. - if (!this.gdLevelManager) { - this.gdLevelManager = new GdLevelManager(this); - this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {} - }); - this.gdLevelManager.setOnDeath((info) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {} - // Респавн игрока через teleport на spawnPoint - try { - const sp = this._spawnPoint || { x: 0, y: 2, z: 0 }; - this.player.teleport(sp.x, sp.y, sp.z); - // Сбросить vy чтобы не нести инерцию из шипа - if (this.player) this.player._vy = 0; - } catch (e) {} - }); - this.gdLevelManager.setOnFinish((info) => { - const stats = this.gdLevelManager.getCoinsStats(); - try { - this.gameRuntime?.routeGlobalEvent?.('message', { - name: 'gdFinish', - data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total }, - }); - } catch (e) {} - try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {} - }); - this.gdLevelManager.setOnCoinCollected((info) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {} - }); - } - this.gdLevelManager.start(); - // Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней. - // Откладываем на setTimeout — primitiveManager.instances наполняется - // не сразу при enterPlayMode (load.primitives асинхронный). - // GD-проект определяется флагом settings.isGd (см. serialize/loadFromState). - // Fallback для старых проектов БЕЗ флага — реальные id GD-уровней: - // - 295: GD 2.0 sandbox - // - 296..306: L1-L11 (эпоха 1 + L11 легаси) - // - 350..358: L12-L20 (эпоха 2) - // Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали, - // и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb, - // нет skybox/forest на заднем фоне). На APK работало правильно. - const _pid = Number(this._currentProjectId); - const isGd = (typeof this._isGdProject === 'boolean') - ? this._isGdProject - : ((_pid >= 296 && _pid <= 315) - || (_pid >= 350 && _pid <= 358) - || _pid === 295); - console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`); - if (isGd) { - // Ширина уровня — по самому правому cube-блоку - let levelWidth = 1000; - if (this.blockManager && this.blockManager.blocks) { - for (const key of this.blockManager.blocks.keys()) { - const x = parseInt(String(key).split(',')[0], 10); - if (Number.isFinite(x) && x > levelWidth) levelWidth = x; - } - } - setTimeout(() => { - try { - if (!this.gdSkybox) { - this.gdSkybox = new GdSkybox(); - const cam = this.player?.camera || this.scene.activeCamera; - this.gdSkybox.attach(this.scene, cam); - console.log('[GD-gfx] skybox attached'); - } - if (!this.gdGroundSkin) { - this.gdGroundSkin = new GdGroundSkin(); - this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this); - console.log('[GD-gfx] groundSkin attached, width=', levelWidth); - } - // Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358. - const pid = Number(this._currentProjectId) || 296; - const GD_PID_TO_EPOCH = { - 296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1, - 306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2, - }; - const epoch = GD_PID_TO_EPOCH[pid] || 1; - if (!this.gdSpikes) { - this.gdSpikes = new GdSpikes(); - this.gdSpikes.attach(this.scene, this, epoch); - } - if (!this.gdStartArch) { - this.gdStartArch = new GdStartArch(); - this.gdStartArch.attach(this.scene, epoch); - } - if (!this.gdPortalArch) { - this.gdPortalArch = new GdPortalArch(); - this.gdPortalArch.attach(this.scene, this, this._currentUserId); - } - if (!this.gdDiamond) { - this.gdDiamond = new GdDiamond(); - this.gdDiamond.attach(this.scene, this); - } - if (!this.gdFinish) { - this.gdFinish = new GdFinish(); - this.gdFinish.attach(this.scene, this, epoch); - } - if (!this.gdForest) { - this.gdForest = new GdForest(); - this.gdForest.attach(this.scene, levelWidth, epoch); - } - if (!this.gdPlayerCube) { - this.gdPlayerCube = new GdPlayerCube(); - this.gdPlayerCube.attach(this.scene, this); - } - if (!this.gdPlayerModeSkin) { - // Задержка 600мс — даём скрипту уровня применить базовый cube-skin, - // чтобы _origTexture при первой смене режима содержала правильную текстуру. - setTimeout(() => { - this.gdPlayerModeSkin = new GdPlayerModeSkin(); - this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId); - }, 600); - } - if (!this.gdPlayerTrail) { - this.gdPlayerTrail = new GdPlayerTrail(); - this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId); - } - if (!this.gdPostFx) { - this.gdPostFx = new GdPostFx(); - const cam = this.player?.camera || this.scene.activeCamera; - this.gdPostFx.attach(this.scene, cam, this); - } - // Тени отключены — делаем через GdGroundSkin (fake shadows) - this._enableGdShadows(); - } catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); } - }, 50); - } - if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); - if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); - if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); - // eslint-disable-next-line no-console - console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); - // Старт через requestAnimationFrame — даём Babylon собрать сцену - requestAnimationFrame(() => { - if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); - }); - - // === Оружие === - if (!this.weapons) this.weapons = new WeaponSystem(this); - if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange); - // Подключаем зомби-логику к попаданиям пули - this.weapons.setOnHit((hit) => { - if (hit?.mesh && this.zombieManager) { - this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); - } - if (this._onWeaponHit) { - try { this._onWeaponHit(hit); } catch (e) {} - } - }); - this.weapons.start(); - - // === Зомби-система === - if (!this.zombieManager) this.zombieManager = new ZombieManager(this); - if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager); - this.zombieManager.start(); - this.spawnerManager.start(); - - // === NPC-система (Фаза 4.1) — управляемые скриптом персонажи === - if (!this.npcManager) this.npcManager = new NpcManager(this); - this.npcManager.start(); - - // === Связи объектов (Фаза 5, Constraints) === - if (!this.constraintManager) this.constraintManager = new ConstraintManager(this); - this.constraintManager.start(); - - // === Лучи и следы (Фаза 5.2 — Beam/Trail) === - if (!this.beamManager) this.beamManager = new BeamManager(this); - this.beamManager.start(); - // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки. - this._activatePointers(); - - // === 3D-звук (Фаза 5.5 — позиционный звук) === - if (!this.soundManager) this.soundManager = new SoundManager(this); - this.soundManager.start(); - // Регистрируем gameplay-объекты: зомби и спавнеры. - // Применяем defaults + текущие gameplayParams из инспектора. - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - const gp = data.gameplay; - if (!gp) continue; - const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) }; - if (gp.isZombie) { - this.zombieManager.registerExisting(data.instanceId, params); - } else if (gp.isZombieSpawner) { - this.spawnerManager.register(data.instanceId, params); - } - } - } - // Снаряжаем оружие из активного слота инвентаря - const active = this.inventory?.getActive?.(); - if (active) this.weapons.equip(active); - - // Замораживаем world-matrix у всех статичных GLB-моделей - // (не зомби и не спавнеры). Деревья, дома, камни не двигаются — - // Babylon не должен пересчитывать их матрицы каждый кадр. - try { this.modelManager?.freezeStaticModels?.(); } catch (e) {} - try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {} - - // ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн. - // Octree создаётся один раз и не «знает» о мешах добавленных позже — - // даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов, - // debris-кубы при смерти, динамические объекты) фактически выпадают - // из активного списка → невидимы. Стандартный frustum-culling Babylon - // дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы. - } - - /** Заглушка для совместимости — раньше пересоздавала octree. */ - setActiveMeshesDirty() { - // no-op - } - - /** Установить колбэк логов от скриптов (для Console-панели UI). */ - setOnScriptLog(cb) { - this._onScriptLog = cb; - if (this.gameRuntime) this.gameRuntime.setOnLog(cb); - } - - /** Колбэк команд HUD от скриптов (для GameHud React-компонента). */ - setOnScriptHud(cb) { - this._onScriptHud = cb; - if (this.gameRuntime) this.gameRuntime.setOnHud(cb); - } - - /** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */ - setOnScriptCrosshair(cb) { - this._onScriptCrosshair = cb; - if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb); - } - - // ============================================================ - // Таймер прохождения (для лидерборда) - // ============================================================ - /** cb({state: 'start'|'stop'|'submit', timeMs}) */ - setOnTimer(cb) { this._onTimer = cb; } - - _timerStart() { - this._timerStartedAt = performance.now(); - this._timerRunning = true; - if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {} - } - _timerStop() { - if (!this._timerRunning) return; - const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); - this._timerRunning = false; - if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {} - } - _timerSubmit() { - if (!this._timerRunning && !this._timerStartedAt) return; - const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); - this._timerRunning = false; - if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {} - } - /** Получить текущее время таймера в мс (или 0 если не запущен). */ - getTimerMs() { - if (!this._timerRunning || !this._timerStartedAt) return 0; - return Math.round(performance.now() - this._timerStartedAt); - } - isTimerRunning() { return !!this._timerRunning; } - - /** PERF-METRICS: получить и сбросить накопленные метрики за окно. */ - flushPerfMetrics() { - const m = this._perfMetrics; - if (!m) return null; - const out = { - render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0, - physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0, - script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0, - idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0, - render_count: m.render_count, - physics_count: m.physics_count, - script_count: m.script_count, - }; - m.render_ms_sum = 0; m.render_count = 0; - m.physics_ms_sum = 0; m.physics_count = 0; - m.script_ms_sum = 0; m.script_count = 0; - m.idle_ms_sum = 0; m.idle_count = 0; - return out; - } - - /** - * Поставить render-loop на паузу. - * Используется когда Babylon canvas не виден (активен таб скрипта), - * чтобы освободить CPU/GPU и Monaco не лагал. - * НЕ останавливает Play-режим — только рендер. - */ - pauseRendering() { this._renderingPaused = true; } - resumeRendering() { this._renderingPaused = false; } - isRenderingPaused() { return !!this._renderingPaused; } - - /** - * Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles - * (через game.scene.spawnParticles в скриптах). - * - * payload: { type, position: {x,y,z}, duration, count, color } - */ - _spawnParticleEffect(payload) { - if (!payload || !this.scene) return; - const pos = payload.position || { x: 0, y: 0, z: 0 }; - const type = payload.type || 'sparks'; - const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5)); - const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1)); - - // Кэшируем текстуру частицы — один раз на сцену - if (!this._particleTex) { - const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); - const ctx = tex.getContext(); - const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 64, 64); - tex.update(); - tex.hasAlpha = true; - this._particleTex = tex; - } - - // MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза - const baseCount = this._isMobileMode ? 40 : 80; - const ps = new ParticleSystem('p_' + Date.now(), - Math.floor(baseCount * countMul), this.scene); - ps.particleTexture = this._particleTex; - ps.emitter = new Vector3(pos.x, pos.y, pos.z); - ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); - ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); - ps.blendMode = ParticleSystem.BLENDMODE_ADD; - - const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color) - ? payload.color : null; - this._configureParticleSystem(ps, type, customColor, countMul); - - ps.start(); - // Авто-стоп: для explosion почти сразу (это burst), для остальных = duration - const stopAt = type === 'explosion' ? 0.05 : duration; - const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3; - setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000); - // dispose(false) — particleTexture расшарена (_particleTex), не удалять. - setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000); - } - - /** - * Настроить параметры ParticleSystem под тип эффекта. - * Общий конфигуратор для разового эффекта (_spawnParticleEffect) и - * постоянного эмиттера-объекта (createEmitterParticles). - */ - _configureParticleSystem(ps, type, customColor, countMul = 1) { - const hexToColor4 = (hex, a = 1) => { - const r = parseInt(hex.substr(1, 2), 16) / 255; - const g = parseInt(hex.substr(3, 2), 16) / 255; - const b = parseInt(hex.substr(5, 2), 16) / 255; - return new Color4(r, g, b, a); - }; - - switch (type) { - case 'fire': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1); - ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1); - ps.colorDead = new Color4(0.2, 0, 0, 0); - ps.minSize = 0.2; ps.maxSize = 0.5; - ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; - ps.emitRate = 80; - ps.gravity = new Vector3(0, 1.2, 0); - ps.direction1 = new Vector3(-0.4, 1.5, -0.4); - ps.direction2 = new Vector3(0.4, 2.0, 0.4); - ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2; - break; - case 'smoke': - ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6); - ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.4; ps.maxSize = 1.2; - ps.minLifeTime = 1.5; ps.maxLifeTime = 3; - ps.emitRate = 40; - ps.gravity = new Vector3(0, 0.5, 0); - ps.direction1 = new Vector3(-0.3, 1, -0.3); - ps.direction2 = new Vector3(0.3, 1.5, 0.3); - ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7; - ps.blendMode = ParticleSystem.BLENDMODE_STANDARD; - break; - case 'sparks': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1); - ps.colorDead = new Color4(0.5, 0.2, 0, 0); - ps.minSize = 0.05; ps.maxSize = 0.15; - ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8; - ps.emitRate = 200; - ps.gravity = new Vector3(0, -8, 0); - ps.direction1 = new Vector3(-3, 4, -3); - ps.direction2 = new Vector3(3, 7, 3); - ps.minEmitPower = 1; ps.maxEmitPower = 3; - break; - case 'magic': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1); - ps.colorDead = new Color4(0.2, 0, 0.5, 0); - ps.minSize = 0.15; ps.maxSize = 0.35; - ps.minLifeTime = 1; ps.maxLifeTime = 2.2; - ps.emitRate = 60; - ps.gravity = new Vector3(0, 0.3, 0); - ps.direction1 = new Vector3(-1, 1, -1); - ps.direction2 = new Vector3(1, 2, 1); - ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5; - break; - case 'explosion': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1); - ps.colorDead = new Color4(0.2, 0, 0, 0); - ps.minSize = 0.3; ps.maxSize = 0.8; - ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; - ps.emitRate = 0; - ps.manualEmitCount = Math.floor(120 * countMul); - ps.gravity = new Vector3(0, -3, 0); - ps.direction1 = new Vector3(-5, -1, -5); - ps.direction2 = new Vector3(5, 5, 5); - ps.minEmitPower = 2; ps.maxEmitPower = 6; - break; - case 'confetti': - ps.color1 = new Color4(1, 0.3, 0.3, 1); - ps.color2 = new Color4(0.3, 0.6, 1, 1); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.1; ps.maxSize = 0.25; - ps.minLifeTime = 1.5; ps.maxLifeTime = 3; - ps.emitRate = 100; - ps.gravity = new Vector3(0, -3, 0); - ps.direction1 = new Vector3(-3, 5, -3); - ps.direction2 = new Vector3(3, 8, 3); - ps.minEmitPower = 1; ps.maxEmitPower = 3; - break; - default: - ps.color1 = new Color4(1, 1, 1, 1); - ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.1; ps.maxSize = 0.3; - ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5; - ps.emitRate = 60; - ps.direction1 = new Vector3(-1, 1, -1); - ps.direction2 = new Vector3(1, 2, 1); - } - } - - /** - * Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.). - * Не имеет авто-стопа — горит пока объект существует. Возвращает ps. - */ - createEmitterParticles(type, position, color) { - if (!this.scene) return null; - if (!this._particleTex) { - const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); - const ctx = tex.getContext(); - const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 64, 64); - tex.update(); - tex.hasAlpha = true; - this._particleTex = tex; - } - const baseCount = this._isMobileMode ? 60 : 120; - const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene); - ps.particleTexture = this._particleTex; - ps.emitter = new Vector3(position.x, position.y, position.z); - ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15); - ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15); - ps.blendMode = ParticleSystem.BLENDMODE_ADD; - const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null; - // explosion как постоянный эффект не имеет смысла → fire - const effType = type === 'explosion' ? 'fire' : type; - this._configureParticleSystem(ps, effType, customColor, 1); - ps.start(); - return ps; - } - - /** - * Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора). - * Если runtime уже есть — переиспользуем, иначе создаём. - */ - startSoloScript(scriptId) { - const all = this._scripts || []; - const sc = all.find(s => s.id === scriptId); - if (!sc) return false; - if (!this.gameRuntime) { - this.gameRuntime = new GameRuntime(this); - try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} - if (!this.gameAudioManager) { - this.gameAudioManager = new GameAudioManager(); - } - if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); - if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); - if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); - } - this.gameRuntime.startSolo(sc); - return true; - } - - /** Остановить отладочный (solo) запуск. */ - stopSoloScript() { - if (this.gameRuntime && this.gameRuntime.isSolo?.()) { - this.gameRuntime.stop(); - // Если не в Play-режиме — освобождаем runtime - if (!this._isPlaying) { - this.gameRuntime = null; - } - } - } - - isSoloRunning() { - return !!this.gameRuntime?.isSolo?.(); - } - getSoloScriptId() { - return this.gameRuntime?.getSoloScriptId?.() || null; - } - - /** Получить все скрипты проекта. */ - getScripts() { return [...this._scripts]; } - - /** Заменить все скрипты (используется при load/edit). */ - setScripts(scripts) { - this._scripts = Array.isArray(scripts) ? scripts.slice() : []; - } - - /** Установить код одного скрипта по id. Если id нет — создать новый. */ - upsertScript(id, code, target = undefined) { - const i = this._scripts.findIndex(s => s.id === id); - if (i >= 0) { - this._scripts[i] = { - ...this._scripts[i], - code, - ...(target !== undefined ? { target } : {}), - }; - } else { - this._scripts.push({ - id: id || `script_${Date.now()}`, - code, - target: target !== undefined ? target : null, - }); - } - // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит - // _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт. - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** Удалить скрипт по id. */ - removeScript(id) { - this._scripts = this._scripts.filter(s => s.id !== id); - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Зарегистрировать колбэк для уведомлений об изменении режима Play - * (вызывается когда player сам инициирует exit, например по Esc). - * KubikonEditor подписывается чтобы синхронизировать React-state. - */ - setOnPlayChange(cb) { - this._onPlayChange = cb; - } - - /** - * Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры - * БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см. - * setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC - * по-прежнему выходит из Play. - */ - setOnEscMenu(cb) { - this._onEscMenu = cb; - } - - /** - * Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают - * НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить - * движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит, - * что меню «открыто», и не откроет его. open=false также возвращает мышь в игру. - */ - setPlayerMenuOpen(open) { - const v = !!open; - if (this._playerMenuOpen === v) return; - this._playerMenuOpen = v; - if (!v) { - // меню закрыли из UI → вернуть управление камерой/мышью - try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ } - } - } - - /** - * Колбэк изменения сцены (любая модификация блоков/моделей). - * Используется KubikonEditor для dirty-tracking → auto-save. - * Сами обработчики на blockManager/modelManager привязаны в init() — - * они дёргают и history.markChange() и this._onSceneChange. - */ - setOnSceneChange(cb) { - this._onSceneChange = cb; - } - - /** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */ - setOnGuiChange(cb) { - this._onGuiChange = cb; - } - - /** Подключить API для пользовательских моделей (Kubikon3DService). - * Нужно дважды: setApi для самого UserModelManager (getUserModel) - * и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */ - setUserModelsApi(api) { - this._userModelsApi = api; - if (this.userModelManager && api) { - this.userModelManager.setApi(api); - } - } - - /** Передать id текущего пользователя — для запросов к приватным моделям. */ - setCurrentUserId(userId) { - this._currentUserId = userId; - } - - /** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API). - * Без этого скрипты не смогут сохранять прогресс. */ - setCurrentProjectId(projectId) { - this._currentProjectId = projectId; - } - - /** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...). - * Редактор/плеер подписываются и реактивно скрывают/показывают элементы. - * Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */ - setOnStdHudVisibilityChange(cb) { - this._onStdHudVisibilityChange = cb; - } - _setStdHudVisible(visible) { - this._stdHudVisible = !!visible; - 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. - * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ - setOnCursorModeChange(cb) { - this._onCursorModeChange = cb; - } - - /** Пересобрать spatial-индекс физики для user-моделей. - * Вызывается из SelectionManager при изменении canCollide / anchored / - * position / rotation / scale. */ - _syncUserModelColliders() { - try { this.physics?.setSpatialDirty?.(); } catch (e) {} - } - - /** Инвалидировать пользовательскую модель после её редактирования. - * Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с - * новой геометрией. Вызывается из KubikonEditor.jsx после закрытия - * редактора модели (когда editingUserModelId != null). */ - async refreshUserModel(userModelId) { - if (!this.userModelManager) return 0; - const rebuilt = await this.userModelManager.invalidateModel(userModelId, { - rebuild: true, - currentUserId: this._currentUserId || null, - }); - // Тени для свежесозданных мешей - if (rebuilt > 0) { - for (const inst of this.userModelManager.instances.values()) { - if (inst.userModelId === userModelId) { - for (const m of inst.meshes) { - try { this.addShadowCaster(m); } catch (e) {} - } - } - } - this._syncUserModelColliders(); - } - return rebuilt; - } - - /** Колбэк изменения инвентаря (для hot-bar React). */ - setOnInventoryChange(cb) { - this._onInventoryChange = cb; - } - - /** Колбэк изменения патронов оружия (для GUI). */ - setOnAmmoChange(cb) { - this._onAmmoChange = cb; - if (this.weapons) this.weapons.setOnAmmoChange(cb); - } - - /** Колбэк попадания пули (для логики урона зомби и др.). */ - setOnWeaponHit(cb) { - this._onWeaponHit = cb; - if (this.weapons) this.weapons.setOnHit(cb); - } - - /** Колбэк изменения HP игрока. */ - setOnPlayerHpChange(cb) { - this._onPlayerHpChange = cb; - if (this.player) this.player.setOnHpChange(cb); - } - - /** Колбэк смерти игрока. */ - setOnPlayerDeath(cb) { - this._onPlayerDeath = cb; - if (this.player) this.player.setOnDeath(cb); - } - - /** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */ - setOnEditorEscape(cb) { - this._onEditorEscape = cb; - } - - getInventoryState() { - return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 }; - } - - setActiveInventorySlot(index) { - this.inventory?.setActive(index); - // Если в Play — пересменяем оружие - if (this._isPlaying && this.weapons) { - const active = this.inventory?.getActive?.(); - if (active && active.kind === 'weapon') { - this.weapons.equip(active); - } else { - this.weapons.unequip(); - } - // Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты - // увидели в руке нашей модели правильный GLB. - if (this._mpSync) { - const modelId = (active && active.kind === 'weapon') - ? (active.modelTypeId || '') - : ''; - try { this._mpSync.sendWeapon(modelId); } catch (e) {} - } - } - } - - addInventoryItem(item) { - return this.inventory?.add(item) ?? -1; - } - - /** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */ - createGuiElement(type, opts) { - return this.guiManager?.create(type, opts); - } - updateGuiElement(id, patch) { - this.guiManager?.update(id, patch); - } - removeGuiElement(id) { - // Если был выделен — снять выделение - if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) { - this.selection.clearSelection?.(); - } - this.guiManager?.remove(id); - } - renameGuiElement(id, name) { - this.guiManager?.rename(id, name); - } - moveGuiElementZ(id, direction) { - this.guiManager?.moveZ(id, direction); - } - getGuiElements() { - return this.guiManager ? this.guiManager.getAll() : []; - } - - // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== - // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). - _ensureSkinShopState() { - if (!this._skinShop) { - this._skinShop = { - open: false, - rev: 0, // ревизия — React видит изменение - data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, - buyResult: null, // последний результат покупки {slug, ok, reason} - }; - } - return this._skinShop; - } - /** Снимок состояния магазина для React (поллинг через rAF). */ - getSkinShopState() { return this._skinShop || null; } - /** Открыть/закрыть магазин (из скрипта или клавиши B). */ - _openSkinShop() { - const s = this._ensureSkinShopState(); - // Отключён в проекте? (скрипт всё равно может открыть через API — - // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). - s.open = true; s.rev++; - } - _closeSkinShop() { - const s = this._ensureSkinShopState(); - s.open = false; s.rev++; - } - toggleSkinShop() { - const s = this._ensureSkinShopState(); - if (s.open) { this._closeSkinShop(); return; } - // Клавиша B открывает магазин только если он включён в проекте. - if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; - this._openSkinShop(); - } - /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ - _setSkinShopData(data) { - const s = this._ensureSkinShopState(); - s.data = { ...s.data, ...data }; - s.rev++; - } - _onSkinBuyResult(res) { - const s = this._ensureSkinShopState(); - s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; - s.rev++; - } - /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ - requestBuySkin(slug, price) { - const rt = this.gameRuntime; - if (!rt) return; - try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} - } - /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ - getAssetDataUrl(slug) { - try { - // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. - const list = this._skinsConfig?.customGlbs || []; - const rec = list.find(g => g && g.slug === slug); - if (rec && rec.dataUrl) return rec.dataUrl; - } catch (e) {} - return null; - } - _onPlayerSkinChanged(slug) { - const s = this._ensureSkinShopState(); - if (s.data) { s.data.current = slug; s.rev++; } - } - - // ===== Библиотека пользовательских картинок (этап 3.6) ===== - - /** Список картинок проекта [{id, name, dataUrl}]. */ - getAssets() { - return this.assetManager ? this.assetManager.list() : []; - } - - /** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */ - addAssetFromFile(file) { - if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' }); - return this.assetManager.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameAsset(id, name) { - if (!this.assetManager) return; - this.assetManager.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались — - * иначе остались бы «висячие» ссылки на несуществующий ассет. - */ - removeAsset(id) { - if (!this.assetManager) return; - this.assetManager.remove(id); - // Снять текстуру с примитивов, использовавших этот ассет. - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - if (data.textureAsset === id) { - this.primitiveManager.updateInstance(data.id, { textureAsset: null }); - } - } - } - // Снять картинку с GUI-элементов image. - if (this.guiManager) { - for (const el of this.guiManager.getAll()) { - if (el.imageAsset === id) { - this.guiManager.update(el.id, { imageAsset: null }); - } - } - } - if (this._onSceneChange) this._onSceneChange(); - } - - // ===== Библиотека пользовательских звуков (Фаза 5.5) ===== - - /** Список звуков проекта [{id, name, dataUrl}]. */ - getSounds() { - return this.soundLibrary ? this.soundLibrary.list() : []; - } - - /** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */ - addSoundFromFile(file) { - if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); - return this.soundLibrary.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameSound(id, name) { - if (!this.soundLibrary) return; - this.soundLibrary.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - removeSound(id) { - if (!this.soundLibrary) return; - this.soundLibrary.remove(id); - if (this._onSceneChange) this._onSceneChange(); - } - - // ===== Библиотека импортированных .glb-моделей (Фаза 5.8) ===== - - /** Список импортированных моделей [{id, name, dataUrl}]. */ - getGlbModels() { - return this.glbLibrary ? this.glbLibrary.list() : []; - } - - /** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */ - addGlbFromFile(file) { - if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); - return this.glbLibrary.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameGlb(id, name) { - if (!this.glbLibrary) return; - this.glbLibrary.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - removeGlb(id) { - if (!this.glbLibrary) return; - this.glbLibrary.remove(id); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Колбэк после постановки нового объекта (блока/модели/примитива). - * Используется KubikonEditor чтобы переключить activeTool на 'select' - * и дать пользователю сразу таскать поставленный объект. - */ - setOnPostPlace(cb) { - this._onPostPlace = cb; - } - - /** - * Сохранить позиции всех unanchored объектов перед стартом физики. - * При exitPlayMode они возвращаются на эти позиции. - */ - _snapshotDynamicObjects() { - this._dynamicSnapshot = []; - if (this.blockManager) { - // Запоминаем позиции unanchored блоков (mesh-position). - // Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная - // логика (сериализация, удаление, выделение) сломается. - // PhysicsAABB при Play фильтрует hasBlock через metadata.anchored - // и не считает unanchored клетку статичным препятствием. - for (const mesh of this.blockManager.blocks.values()) { - if (mesh.metadata?.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'block', - mesh, - x: mesh.position.x, y: mesh.position.y, z: mesh.position.z, - rotX: mesh.rotation?.x || 0, - rotY: mesh.rotation?.y || 0, - rotZ: mesh.rotation?.z || 0, - }); - } - } - } - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - if (data.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'primitive', data, - x: data.x, y: data.y, z: data.z, - rotX: data.mesh?.rotation?.x || 0, - rotY: data.mesh?.rotation?.y || 0, - rotZ: data.mesh?.rotation?.z || 0, - }); - } - } - } - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - if (data.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'model', data, - x: data.x, y: data.y, z: data.z, - rotX: data.rootMesh?.rotation?.x || 0, - rotY: data.rootMesh?.rotation?.y || 0, - rotZ: data.rootMesh?.rotation?.z || 0, - }); - } - } - } - } - - _restoreDynamicObjects() { - if (!this._dynamicSnapshot) return; - for (const snap of this._dynamicSnapshot) { - if (snap.kind === 'block' && snap.mesh) { - snap.mesh.position.x = snap.x; - snap.mesh.position.y = snap.y; - snap.mesh.position.z = snap.z; - if (snap.mesh.rotation) { - snap.mesh.rotation.x = snap.rotX || 0; - snap.mesh.rotation.y = snap.rotY || 0; - snap.mesh.rotation.z = snap.rotZ || 0; - } - snap.mesh.setEnabled(true); - } else if (snap.kind === 'primitive' && snap.data) { - snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; - if (snap.data.mesh) { - snap.data.mesh.position.set(snap.x, snap.y, snap.z); - if (snap.data.mesh.rotation) { - snap.data.mesh.rotation.x = snap.rotX || 0; - snap.data.mesh.rotation.y = snap.rotY || 0; - snap.data.mesh.rotation.z = snap.rotZ || 0; - } - snap.data.mesh.setEnabled(snap.data.visible !== false); - } - } else if (snap.kind === 'model' && snap.data) { - snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; - if (snap.data.rootMesh) { - snap.data.rootMesh.position.set(snap.x, snap.y, snap.z); - if (snap.data.rootMesh.rotation) { - snap.data.rootMesh.rotation.x = snap.rotX || 0; - snap.data.rootMesh.rotation.y = snap.rotY || 0; - snap.data.rootMesh.rotation.z = snap.rotZ || 0; - } - snap.data.rootMesh.setEnabled(true); - } - } - } - this._dynamicSnapshot = null; - } - - /** - * Полный снимок сцены перед Play — примитивы и модели целиком. - * При exitPlayMode сцена восстанавливается ровно к этому состоянию: - * вернутся удалённые скриптом объекты, откатятся цвет/видимость/ - * коллизия/поворот, исчезнут заспавненные скриптом объекты. - * - * Зачем: скрипты игр меняют сцену деструктивно (game.self.delete, - * setColor, tween rotationY и т.д.). Без полного отката после - * Stop→Play сцена остаётся «использованной» — собранные монетки - * не появляются, открытая дверь остаётся открытой. Это как Stop - * в Roblox Studio: сцена возвращается к авторскому виду. - * - * Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная - * пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так - * откатываются через _restoreDynamicObjects (позиции). - */ - _snapshotFullScene() { - this._fullSceneSnapshot = null; - try { - const snap = {}; - if (this.primitiveManager) { - snap.primitives = this.primitiveManager.serialize(); - } - if (this.modelManager) { - snap.models = this.modelManager.serialize(); - } - this._fullSceneSnapshot = snap; - } catch (e) { - console.warn('[BabylonScene] _snapshotFullScene не удался:', e); - this._fullSceneSnapshot = null; - } - } - - /** - * Восстановить сцену из полного снимка после Play. - * Пересоздаёт примитивы и модели точь-в-точь (id сохраняются — - * addInstance принимает opts.id, поэтому скрипты на объектах после - * рестарта снова найдут свои target.id). - */ - _restoreFullScene() { - if (!this._fullSceneSnapshot) return; - const snap = this._fullSceneSnapshot; - this._fullSceneSnapshot = null; - try { - // Сбрасываем выделение — loadFromArray диспозит старые mesh, - // selection не должен держать мёртвую ссылку. - try { this.selection?.clear?.(); } catch (e) {} - if (this.primitiveManager && Array.isArray(snap.primitives)) { - this.primitiveManager.loadFromArray(snap.primitives); - } - if (this.modelManager && Array.isArray(snap.models)) { - // loadFromArray у моделей async (модели грузятся с диска) — - // не ждём, восстановление догрузится в фоне. - Promise.resolve(this.modelManager.loadFromArray(snap.models)) - .catch((e) => console.warn('[BabylonScene] откат моделей:', e)); - } - } catch (e) { - console.warn('[BabylonScene] _restoreFullScene не удался:', e); - } - } - - /** Поменять anchored у выделенного объекта. */ - setSelectedAnchored(anchored) { - this.selection?.setSelectedAnchored(anchored); - } - - /** === Окружение / Время суток / Аудио / Вода === */ - setEnvironmentPreset(preset) { this.environment?.setPreset(preset); } - setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); } - setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); } - setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); } - getEnvironmentState() { return this.environment?.serialize() || null; } - - setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); } - setMusicAudio(opts) { this.audioManager?.setMusic(opts); } - getAudioState() { return this.audioManager?.serialize() || null; } - - /** Доступные пресеты амбиента/музыки для UI. */ - getAudioPresets() { - return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] }; - } - - /** Доступные модели игрока (категория «Персонажи»). */ - getPlayerOptions() { - // Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache - return this._playerOptionsCache || []; - } - setPlayerOptions(list) { this._playerOptionsCache = list; } - - /** Обновить пресет амбиента/музыки и обновить selection если открыт. */ - setSoundProps(patch) { - if (!patch) return; - if (patch.ambientId !== undefined) { - this.audioManager?.setAmbient({ preset: patch.ambientId }); - } - if (patch.musicId !== undefined) { - this.audioManager?.setMusic({ preset: patch.musicId }); - } - if (this.selection?._selection?.type === 'sound') { - this.selection.selectSound(); - } - } - - /** Обновить тип персонажа / силу прыжка / прицел. */ - setPlayerProps(patch) { - if (!patch) return; - if (patch.playerModelType) { - this.setPlayerModelType(patch.playerModelType); - } - if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) { - this.setPlayerJumpPower(patch.jumpPower); - } - if (typeof patch.crosshair === 'string') { - this.setCrosshair(patch.crosshair); - } - if (this.selection?._selection?.type === 'player') { - this.selection.selectPlayer(); - } - } - - /** Поменять mass у выделенного объекта. */ - setSelectedMass(mass) { - this.selection?.setSelectedMass(mass); - } - - /** Поменять свойства модели (canCollide / visible). */ - setSelectedModelProps(patch) { - if (!this.selection) return; - const sel = this.selection.getSelection(); - if (sel?.type === 'userModel') { - this.selection.setSelectedUserModelProps(patch); - return; - } - this.selection.setSelectedModelProps(patch); - } - - /** Поменять свойства блока (canCollide / visible). */ - setSelectedBlockProps(patch) { - this.selection?.setSelectedBlockProps(patch); - } - - /** === Папки/группы === */ - createFolder(name = 'Новая папка', parentId = null) { - return this.folderManager?.createFolder(name, parentId) ?? null; - } - renameFolder(id, name) { this.folderManager?.renameFolder(id, name); } - - /** Переименовать скрипт по id. Имя сохраняется в поле name. */ - renameScript(id, name) { - const i = this._scripts.findIndex(s => s.id === id); - if (i < 0) return false; - this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null }; - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - return true; - } - - /** Переименовать инстанс модели. */ - renameModel(instanceId, name) { - const data = this.modelManager?.instances?.get(instanceId); - if (!data) return false; - data.name = String(name || '').trim() || null; - if (this._onSceneChange) this._onSceneChange(); - this.modelManager?._notifyChange?.(); - return true; - } - - /** Переименовать примитив. */ - renamePrimitive(id, name) { - const data = this.primitiveManager?.instances?.get(id); - if (!data) return false; - data.name = String(name || '').trim() || null; - if (this._onSceneChange) this._onSceneChange(); - this.primitiveManager?._notifyChange?.(); - return true; - } - removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); } - setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); } - assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); } - /** Положить выделенное в указанную папку (или null = в корень). */ - assignSelectionToFolder(folderId) { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId); - } else if (sel.type === 'model') { - this.folderManager?.assignToFolder('model', sel.instanceId, folderId); - } else if (sel.type === 'primitive') { - this.folderManager?.assignToFolder('primitive', sel.id, folderId); - } - } - - /** Undo. */ - undo() { - return this.history?.undo(); - } - - /** Redo. */ - redo() { - return this.history?.redo(); - } - - /** Можно ли откатиться. */ - canUndo() { - return !!this.history?.canUndo(); - } - - /** Можно ли вернуть. */ - canRedo() { - return !!this.history?.canRedo(); - } - - /** - * Захватить превью-скриншот сцены как data URL (PNG, base64). - * Используется для иконки проекта в «Мои игры». - * size — размер квадратного превью в пикселях (по умолчанию 256). - */ - captureThumbnail(size = 256) { - if (!this.canvas) return null; - try { - // Простейший способ — взять текущий canvas-buffer и масштабировать его - // в новый offscreen canvas размера size×size. - const out = document.createElement('canvas'); - out.width = size; - out.height = size; - const ctx = out.getContext('2d'); - // Чёрная заливка на случай прозрачности - ctx.fillStyle = '#1a1410'; - ctx.fillRect(0, 0, size, size); - // Принудительный рендер чтобы backbuffer был свежим - if (this.scene) this.scene.render(); - // Сохраняем сохраняя пропорции — рисуем по короткой стороне - const sw = this.canvas.width, sh = this.canvas.height; - const minSide = Math.min(sw, sh); - const sx = (sw - minSide) / 2; - const sy = (sh - minSide) / 2; - ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size); - return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ - } catch (e) { - // eslint-disable-next-line no-console - console.error('[BabylonScene] thumbnail error:', e); - return null; - } - } - - // === СОХРАНЕНИЕ И ЗАГРУЗКА =========================================== - - /** - * Сериализовать сцену в JSON-объект для сохранения в БД. - * Включает: блоки, модели, точку спавна, позицию редактор-камеры. - */ - /** - * Подготовить мини-карту для уже загруженного проекта (когда нет - * GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в - * window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно. - * MinimapOverlay должна сама уметь рендерить real-data fallback. - */ - _setupMinimapForLoadedProject() { - if (!this.terrainManager || !this.terrainManager.voxels) return; - const voxels = this.terrainManager.voxels; - if (voxels.size === 0) return; - // Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize. - let maxAbs = 0; - for (const key of voxels.keys()) { - const lastComma = key.lastIndexOf(','); - const midComma = key.lastIndexOf(',', lastComma - 1); - const x = parseInt(key.slice(0, midComma), 10); - const z = parseInt(key.slice(lastComma + 1), 10); - const ax = Math.abs(x); - const az = Math.abs(z); - if (ax > maxAbs) maxAbs = ax; - if (az > maxAbs) maxAbs = az; - } - // maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас). - const size = Math.ceil(maxAbs * 1.1); - window.__lastGenSize = size; - // Если ничего не было сгенерировано — НЕ ставим __lastGenParams. - // MinimapOverlay использует fallback на real-data top-down рендер. - console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`); - } - - /** - * Подготовить мини-карту для гладкого ландшафта (RobloxTerrain). - * - * Воксельная миникарта (_setupMinimapForLoadedProject) читает - * terrainManager.voxels — для smooth terrain их нет. Здесь публикуем - * ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay - * сам строит top-down heightmap из неё. - * - * Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible - * (он показывается по этому флагу), даже если у гладкого ландшафта - * нет настоящего streaming-режима. - */ - _setupMinimapForRobloxTerrain() { - const grid = this._robloxTerrain?.grid; - if (!grid) return; - // CELL_SIZE=4м. Полная ширина карты в метрах. - const worldM = grid.size.x * 4; - // MinimapOverlay масштаб: getWorldViewM() = size*2*0.25. - // Чтобы worldViewM == worldM → size = worldM*2. - window.__lastGenSize = worldM * 2; - window.__lastGenParams = null; // не procedural-режим - window.__robloxMinimapGrid = grid; // density-grid для real-data рендера - this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым - // ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это - // зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались - // к центру и проваливались). Подгоняем под реальный размер карты. - const half = Math.ceil(worldM / 2); - if (this._worldHalf < half) { - this._worldHalf = half; - // Синхронизируем physics.floorHalf — иначе игрок проваливается - // сквозь baseplate за пределами центральных 80×80. - if (this.physics) this.physics.floorHalf = half; - console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`); - } - console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`); - } - - /** - * Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain). - * - * Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит - * сглаженную поверхность между ячейками — реальная видимая высота - * отличается от грубой оценки по grid. Чтобы корректно ставить - * объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша. - * - * Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек - * (шаг step метров) и возвращает объект с картой высот. Используется - * билд-скриптами игр для точного размещения. - * - * @param {number} step шаг сетки в метрах (по умолчанию 2) - * @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] } - * heights — плоский массив (rows × cols), значение = Y поверхности или null. - */ - exportRobloxHeightmap(step = 2) { - const rt = this._robloxTerrain; - if (!rt || !rt.grid) { - console.warn('[exportRobloxHeightmap] нет гладкого ландшафта'); - return null; - } - const grid = rt.grid; - const CS = 4; // CELL_SIZE - // Мировые границы карты по grid. - const minWX = grid.origin.x * CS; - const minWZ = grid.origin.z * CS; - const worldX = grid.size.x * CS; - const worldZ = grid.size.z * CS; - const cols = Math.ceil(worldX / step) + 1; - const rows = Math.ceil(worldZ / step) + 1; - - // Предикат: только меши гладкого ландшафта. - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - const heights = new Array(cols * rows).fill(null); - let hit = 0; - const t0 = performance.now(); - for (let r = 0; r < rows; r++) { - const wz = minWZ + r * step; - for (let c = 0; c < cols; c++) { - const wx = minWX + c * step; - const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000); - const pick = this.scene.pickWithRay(ray, pickPred); - if (pick && pick.hit && pick.pickedPoint) { - heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3); - hit++; - } - } - } - const dt = (performance.now() - t0).toFixed(0); - console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`); - - return { - format: 'roblox-heightmap-v1', - origin: { x: minWX, z: minWZ }, - worldSize: { x: worldX, z: worldZ }, - gridOrigin: { ...grid.origin }, - gridSize: { ...grid.size }, - step, - cols, - rows, - heights, - }; - } - - serialize() { - // Принадлежность объектов папкам — серилизуется в их собственных - // данных (folderId), а сами папки в отдельном массиве. - const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; - // BlockManager.serialize не знает про folderId — добавляем его поверх. - if (this.blockManager) { - for (const item of blocksWithFolders) { - const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); - item.folderId = mesh?.metadata?.folderId ?? null; - } - } - const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; - if (this.modelManager) { - // Дописываем instanceId + folderId поверх стандартной сериализации - // (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass) - const live = Array.from(this.modelManager.instances.values()); - for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) { - modelsWithFolders[i].instanceId = live[i].instanceId; - modelsWithFolders[i].folderId = live[i].folderId ?? null; - } - } - const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : []; - if (this.primitiveManager) { - for (let i = 0; i < primitivesWithFolders.length; i++) { - const id = primitivesWithFolders[i].id; - const data = this.primitiveManager.instances.get(id); - primitivesWithFolders[i].folderId = data?.folderId ?? null; - } - } - // Terrain: RLE-формат для больших карт (×25 меньше чем legacy array). - // На карте 250м: ~1.5МБ вместо ~38МБ. - let terrainData; - if (this.terrainManager) { - const voxelCount = this.terrainManager.voxels?.size ?? 0; - if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') { - terrainData = this.terrainManager.serializeRLE(); - } else { - terrainData = this.terrainManager.serialize(); - } - } else { - terrainData = []; - } - // Roblox smooth terrain — отдельная подсистема, сериализуется параллельно - let robloxTerrainData = null; - if (this._robloxTerrain && this._robloxTerrain.grid) { - try { - robloxTerrainData = this._robloxTerrain.serialize(); - // Декорации сохраняем двумя способами: - // - decoParams (seed + density) — для процедурной генерации - // - decoInstances (полные матрицы) — для ручных правок plant-кистью - // При load — приоритет у decoInstances если они есть. - if (this._smoothDecoParams) { - robloxTerrainData.decoParams = this._smoothDecoParams; - } - if (this._smoothDecoManager) { - const stats = this._smoothDecoManager.getStats?.(); - if (stats && stats.total > 0) { - try { - robloxTerrainData.decoInstances = this._smoothDecoManager.serialize(); - } catch (e) { - console.warn('smoothDeco serialize failed:', e); - } - } - } - } catch (e) { console.warn('robloxTerrain serialize failed:', e); } - } - - return { - version: 1, - scene: { - blocks: blocksWithFolders, - models: modelsWithFolders, - primitives: primitivesWithFolders, - // Этап 5: пользовательские воксельные модели (созданные через - // ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }. - userModels: this.userModelManager ? this.userModelManager.serialize() : [], - terrain: terrainData, - robloxTerrain: robloxTerrainData, - decorations: this.decoManager ? this.decoManager.serialize() : [], - folders: this.folderManager ? this.folderManager.serialize() : [], - gui: this.guiManager ? this.guiManager.serialize() : [], - inventory: this.inventory ? this.inventory.serialize() : null, - spawnPoint: { ...this._spawnPoint }, - playerModelType: this._playerModelType, - skins: this._skinsConfig ? { - default: this._skinsConfig.default || null, - unlocked: this._skinsConfig.unlocked || [], - shopVisible: this._skinsConfig.shopVisible !== false, - coins: this._skinsConfig.coins || 0, - customGlbs: this._skinsConfig.customGlbs || [], - } : undefined, - worldSize: this._worldHalf * 2, - floorEnabled: this._floorEnabled !== false, - jumpPowerMul: this._jumpPowerMul ?? 1, - cameraMode: this._defaultCameraMode || 'third', - crosshair: this._crosshair || 'dot', - shadowQuality: this._shadowQuality || 'soft', - environment: this.environment ? this.environment.serialize() : null, - audio: this.audioManager ? this.audioManager.serialize() : null, - // Библиотека пользовательских картинок (текстуры/GUI-image). - assets: this.assetManager ? this.assetManager.serialize() : [], - // Библиотека пользовательских звуков (Фаза 5.5). - sounds: this.soundLibrary ? this.soundLibrary.serialize() : [], - // Импортированные .glb-модели (Фаза 5.8). - glbModels: this.glbLibrary ? this.glbLibrary.serialize() : [], - // ЭТАП 2.1: пропускаем demo-скрипт (он добавляется автоматически - // при загрузке если у проекта нет своих скриптов). - scripts: this._scripts - .filter(s => s.id !== 'demo') - .map(s => ({ - id: s.id, - code: s.code, - target: s.target || null, - name: s.name || null, - })), - }, - editorCamera: this.camera ? { - position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }, - rotation: { x: this.camera.rotation.x, y: this.camera.rotation.y, z: this.camera.rotation.z }, - } : null, - settings: { - // GD-проект (Geometry Dash) — включает GD-визуал в Play. - // Раньше определялось по диапазону id (296-395), но диапазон - // зарезервирован с запасом — обычные проекты туда попадали. - // Теперь — явный флаг в данных проекта. - isGd: this._isGdProject === true, - }, - }; - } - - /** - * Восстановить сцену из ранее сохранённого state. - * Очищает существующие блоки/модели, создаёт заново. - * Возвращает promise (async из-за загрузки моделей). - */ - async loadFromState(state) { - if (!state || !state.scene) return; - - // Флаг GD-проекта — из settings. Если в данных проекта флага нет - // (старые проекты до введения флага) — _isGdProject останется - // undefined, и enterPlayMode сделает fallback на диапазон id. - if (state.settings && typeof state.settings.isGd === 'boolean') { - this._isGdProject = state.settings.isGd; - } - - // Библиотека пользовательских картинок — грузим РАНЬШЕ примитивов, - // чтобы при создании примитива с textureAsset текстура уже была доступна. - if (this.assetManager) { - this.assetManager.load(Array.isArray(state.scene.assets) ? state.scene.assets : []); - } - // Библиотека пользовательских звуков (Фаза 5.5). - if (this.soundLibrary) { - this.soundLibrary.load(Array.isArray(state.scene.sounds) ? state.scene.sounds : []); - } - // Импортированные .glb-модели (Фаза 5.8) — грузим РАНЬШЕ моделей, - // чтобы при addInstance('glb:N') библиотека была готова. - if (this.glbLibrary) { - this.glbLibrary.load(Array.isArray(state.scene.glbModels) ? state.scene.glbModels : []); - } - - // Размер пола — пересоздаём пол если у проекта он другой - if (typeof state.scene.worldSize === 'number' && state.scene.worldSize > 0) { - this.setWorldSize(state.scene.worldSize); - } - if (typeof state.scene.floorEnabled === 'boolean') { - this.setFloorEnabled(state.scene.floorEnabled); - } - if (typeof state.scene.jumpPowerMul === 'number' && state.scene.jumpPowerMul > 0) { - this.setPlayerJumpPower(state.scene.jumpPowerMul); - } - if (typeof state.scene.crosshair === 'string') { - this.setCrosshair(state.scene.crosshair); - } - // Камера по умолчанию ('first' / 'third' / 'front'). Применяется при enterPlayMode. - if (typeof state.scene.cameraMode === 'string') { - this._defaultCameraMode = state.scene.cameraMode; - } - // Качество теней - if (state.scene.shadowQuality) { - this.setShadowQuality(state.scene.shadowQuality); - } - - // Блоки — синхронно - if (this.blockManager && Array.isArray(state.scene.blocks)) { - this.blockManager.loadFromArray(state.scene.blocks); - } - - // Террейн (voxel-ландшафт). Поддерживаем 2 формата: - // 1. Legacy: terrain = [{x,y,z,m}, ...] — старые проекты - // 2. RLE-v1: terrain = {format:'rle-v1', palette, chunks:{base64}} - // — новый формат для больших карт (×25 меньше) - const ts = state.scene.terrain; - // Прогресс-индикатор: глобальный объект, KubikonEditor.jsx читает в polling - const setProgress = (percent, label) => { - if (typeof window !== 'undefined') { - window.__kubikonLoadProgress = { percent, label, ts: performance.now() }; - } - }; - setProgress(2, 'Подготовка сцены…'); - if (this.terrainManager && ts) { - const tLoad0 = performance.now(); - if (Array.isArray(ts)) { - // Legacy формат - const tCount = ts.length; - console.log(`[BabylonScene] LOAD terrain (legacy): ${tCount} voxels`); - await this.terrainManager.loadFromArray(ts, (loaded, total) => { - if (total > 5000 && loaded % 10000 < 2001) { - console.log(`[BabylonScene] terrain: ${loaded}/${total}`); - // 5-40% — заливка вокселей - setProgress(5 + Math.floor((loaded / total) * 35), `Размещение блоков: ${loaded.toLocaleString()} / ${total.toLocaleString()}`); - } - }); - } else if (ts.format === 'rle-v1' && typeof this.terrainManager.loadFromRLE === 'function') { - // RLE формат - const chunkCount = Object.keys(ts.chunks || {}).length; - console.log(`[BabylonScene] LOAD terrain (RLE): ${chunkCount} chunks, palette=${(ts.palette || []).length - 1}`); - await this.terrainManager.loadFromRLE(ts, (loaded, total) => { - if (loaded % 16 === 0) { - console.log(`[BabylonScene] terrain RLE: chunk ${loaded}/${total}`); - // 5-40% — распаковка RLE - setProgress(5 + Math.floor((loaded / total) * 35), `Распаковка карты: ${loaded} / ${total} чанков`); - } - }); - } else { - console.warn('[BabylonScene] unknown terrain format:', ts); - } - setProgress(75, 'Сборка геометрии регионов…'); - const tLoad1 = performance.now(); - const finalVoxelCount = this.terrainManager.voxels?.size ?? 0; - const regionCount = this.terrainManager.getRegionCount?.() ?? 0; - console.log(`[BabylonScene] LOAD done in ${(tLoad1 - tLoad0).toFixed(0)}ms: ${finalVoxelCount} voxels → ${regionCount} regions`); - - // Shadow-load в VoxelWorld — ТОЛЬКО для маленьких карт. - // На больших (>30K voxels) это лишняя нагрузка (память + время). - // Если когда-то перейдём на новый рендер — сами решим shadow-load заново. - if (Array.isArray(ts) && finalVoxelCount > 0 && finalVoxelCount < 30000) { - try { - this.voxelWorld.loadLegacyTerrain(ts); - const s = this.voxelWorld.stats(); - console.log(`[VoxelWorld] shadow-loaded: ${s.totalVoxels} voxels in ${s.totalChunks} chunks`); - } catch (e) { - console.warn('[VoxelWorld] shadow-load failed:', e); - } - } else if (finalVoxelCount >= 30000) { - console.log(`[VoxelWorld] shadow-load SKIPPED (${finalVoxelCount} voxels > 30000 — экономим память)`); - } - - // === АВТО-STREAMING для загруженных больших проектов === - if (regionCount > 0) { - this._terrainStreamingEnabled = true; - // Адаптивный radius: чем больше карта, тем меньше radius. - // Поднял пороги (2026-05-27): на средних картах (1-3M voxels) - // streaming-radius 28-32м слишком мал для замкнутых объектов - // типа вулкана — игрок видит «полупрозрачные» стены, потому - // что дальние регионы стенки не материализованы. - let radius = 80; - if (finalVoxelCount > 5_000_000) radius = 40; - else if (finalVoxelCount > 3_000_000) radius = 55; - else if (finalVoxelCount > 1_000_000) radius = 70; - this._terrainStreamingRadius = radius; - this._terrainStreamingLastUpdate = 0; - // Автотуман для скрытия границы streaming. Без него видно - // резкий обрыв террейна на радиусе. - // - // Density подбираем по editor-radius: чем больше radius, - // тем дальше начало тумана. Для radius=72м: 0.005 — туман - // едва заметен в ближнем плане, но прячет обрыв на 70м. - try { - if (this.scene) { - const camY = this.camera?.position.y || 0; - const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); - const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); - // Эмпирически: fogDensity ≈ 0.5/radius работает. - // На radius=72м → 0.007 (туман на 70-90м) - // На radius=40м → 0.0125 (туман на 40-60м, как раньше) - const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius)); - this.scene.fogMode = 2; // FOGMODE_EXP - this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой - this.scene.fogDensity = density; - } - } catch (e) {} - // Сразу первый pass с editor-radius формулой (см. render loop). - if (this.camera) { - const camY = this.camera.position.y || 0; - const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); - const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); - const r = this.terrainManager.updateStreaming( - this.camera.position.x, this.camera.position.z, - editorRadius, - ); - console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`); - } - this._setupMinimapForLoadedProject(); - } else { - console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`); - } - } - - // === Загрузка Roblox smooth terrain (параллельная подсистема) === - // Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid. - const rts = state.scene.robloxTerrain; - if (rts && rts.format === 'robloxterrain-v1') { - try { - setProgress(90, 'Загрузка гладкого ландшафта…'); - if (!this._robloxTerrain) { - this._robloxTerrain = new RobloxTerrain(this.scene); - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - } - this._robloxTerrain.loadFromState(rts); - // Сразу материализуем chunks вокруг камеры - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); - const stats = this._robloxTerrain.getStats(); - console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`); - // Включаем мини-карту для гладкого ландшафта — MinimapOverlay - // показывается по флагу _terrainStreamingEnabled, а heightmap - // строит из density-grid (window.__robloxMinimapGrid). - this._setupMinimapForRobloxTerrain(); - // Если есть RobloxTerrain — отключаем baseplate-floor чтобы - // не создавал ложных коллизий под smooth-ландшафтом. - // НО только если рельеф большой (≥500 cells) — иначе baseplate - // нужен для визуального ориентирования и для plant-decos - // которые ставятся на y=0. - if (stats.solidCells >= 500) { - try { this.setFloorEnabled(false); } catch (e) {} - } - // === Загрузка smooth-decorations === - // Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью) - // Приоритет 2: decoParams (seed-based процедурная генерация) - if (rts.decoInstances && rts.decoInstances.items?.length > 0) { - try { - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - const r = await this._smoothDecoManager.loadFromState(rts.decoInstances); - const total = r?.total ?? 0; - const tc = r?.treeColliders ?? []; - console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`); - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(tc); - } - } catch (err) { - console.error('[BabylonScene] smooth decoInstances load failed:', err); - } - } else if (rts.decoParams) { - try { - const dp = rts.decoParams; - this._smoothDecoParams = dp; - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - await this._smoothDecoManager.loadAll(); - const decoGen = new WorldGenerator(dp.genParams); - const sampleSurfaceY = (x, z) => { - if (!this.physics?._sampleRobloxSurface) return null; - return this.physics._sampleRobloxSurface(x, z); - }; - const sampleBiomeId = (x, z) => { - const b = decoGen.sampleBiome(x * 4, z * 4); - return b?.id; - }; - const r2 = this._smoothDecoManager.placeDecorations({ - sampleSurfaceY, sampleBiomeId, bbox: dp.bbox, - densityFlowers: dp.flowersDensity, - densityGrass: dp.grassDensity, - densityTrees: dp.treesDensity ?? 0, - seed: dp.seed, - }); - console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`); - if (this.physics?.setSmoothDecoTrees && r2.treeColliders) { - this.physics.setSmoothDecoTrees(r2.treeColliders); - } - } catch (err) { - console.error('[BabylonScene] smooth decorations load failed:', err); - } - } - } catch (err) { - console.error('[BabylonScene] robloxTerrain load failed:', err); - } - } - - setProgress(95, 'Размещение декораций…'); - // Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями). - if (this.decoManager && Array.isArray(state.scene.decorations)) { - this.decoManager.loadFromArray(state.scene.decorations); - // Этап D LOD: первый pass streaming для деко. - // maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется - // мгновенно). Остальные доступные подгружаются по 2-4 за тик - // (200мс), весь видимый набор за 1-2 секунды. UI не блокируется. - if (this.camera && this.decoManager.updateStreaming) { - const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); - const r = this.decoManager.updateStreaming( - this.camera.position.x, this.camera.position.z, decoRadius, - { maxBuild: 2 }, - ); - console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`); - } - } - - // Модели — асинхронно (GLB подгружаются) - if (this.modelManager && Array.isArray(state.scene.models)) { - await this.modelManager.loadFromArray(state.scene.models); - } - - // Этап 5: пользовательские воксельные модели — асинхронно - // (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}. - if (this.userModelManager && Array.isArray(state.scene.userModels) - && state.scene.userModels.length > 0) { - const loaded = await this.userModelManager.loadFromArray( - state.scene.userModels, - { currentUserId: this._currentUserId }, - ); - console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`); - // Регистрируем коллайдеры в физике - 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) тоже — - // PlayerController.start() её ждёт, но если предзагрузить сейчас, - // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. - // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters// - // body.glb + манифест), ModelManager их не знает. Их грузит сам - // PlayerController через _loadSkinManifest — здесь пропускаем, - // иначе ModelManager пишет в консоль 'Unknown model type'. - try { - const playerModelType = this._playerModelType || 'character-a'; - if (!String(playerModelType).startsWith('skin_')) { - await this.modelManager?._loadPrototype?.(playerModelType); - } - } catch (e) { /* ignore */ } - - // Примитивы — синхронно - if (this.primitiveManager && Array.isArray(state.scene.primitives)) { - this.primitiveManager.loadFromArray(state.scene.primitives); - } - - // Папки + восстановление folderId на всех объектах - if (this.folderManager) { - this.folderManager.loadFromArray(state.scene.folders || []); - } - // GUI-элементы - if (this.guiManager) { - this.guiManager.loadFromArray(state.scene.gui || []); - } - // Инвентарь - if (this.inventory) { - this.inventory.loadFromArray(state.scene.inventory || null); - } - // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) - if (this.blockManager && Array.isArray(state.scene.blocks)) { - for (const b of state.scene.blocks) { - if (b.folderId == null) continue; - const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`); - if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId; - } - } - if (this.modelManager && Array.isArray(state.scene.models)) { - // ModelManager.loadFromArray генерирует новые instanceId, - // поэтому folderId восстанавливаем по индексу (порядку). - const arr = state.scene.models; - const liveIds = Array.from(this.modelManager.instances.keys()); - for (let i = 0; i < arr.length && i < liveIds.length; i++) { - if (arr[i].folderId == null) continue; - const data = this.modelManager.instances.get(liveIds[i]); - if (data) data.folderId = arr[i].folderId; - } - } - if (this.primitiveManager && Array.isArray(state.scene.primitives)) { - // primitiveManager после loadFromArray генерирует новые id, поэтому - // восстановим folderId по индексу (порядку) — он совпадает. - const arr = state.scene.primitives; - const liveIds = Array.from(this.primitiveManager.instances.keys()); - for (let i = 0; i < arr.length && i < liveIds.length; i++) { - if (arr[i].folderId == null) continue; - const data = this.primitiveManager.instances.get(liveIds[i]); - if (data) data.folderId = arr[i].folderId; - } - } - // После расстановки folderId — применим эффективную видимость папок - if (this.folderManager) { - for (const f of this.folderManager.getAll()) { - this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id)); - } - } - - // Зарегистрировать все объекты как shadow casters - this.refreshAllShadows(); - - // Точка спавна - if (state.scene.spawnPoint) { - this._spawnPoint = { ...state.scene.spawnPoint }; - this._updateSpawnMarker(); - } - // === Авто-fix спавна для smooth terrain === - // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — - // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". - // Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh - // обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает - // в бездну. - try { - if (this._robloxTerrain - && (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0 - && this.physics?._sampleRobloxSurface) { - const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z); - if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) { - const newY = surfaceY + 2; - console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`); - this._spawnPoint.y = newY; - this._updateSpawnMarker(); - } else if (surfaceY === null) { - console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn'); - } - } - } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } - // (Тип модели персонажа и skins решены выше — до предзагрузки модели.) - // Пользовательские скрипты - if (Array.isArray(state.scene.scripts)) { - this._scripts = state.scene.scripts - .filter(s => s && typeof s.code === 'string') - .map(s => ({ - id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, - code: s.code, - target: s.target || null, - name: s.name || null, - })); - } - // Окружение (время суток, скайбокс, туман) - if (state.scene.environment && this.environment) { - this.environment.load(state.scene.environment); - } - // Аудио (фоновая музыка/амбиент) - if (state.scene.audio && this.audioManager) { - this.audioManager.load(state.scene.audio); - } - - // Редактор-камера - if (state.editorCamera && this.camera) { - const c = state.editorCamera; - if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z); - if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z); - } - // Финальный прогресс — UI скроет overlay - if (typeof window !== 'undefined') { - window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() }; - } - } - - /** - * Задача 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() { - if (!this._isPlaying) return; - this._isPlaying = false; - // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе - try { this.modalManager?._instantClose?.(); } catch (e) {} - // Сбрасываем таймер прохождения - this._timerRunning = false; - this._timerStartedAt = null; - // Отключаем picking voxel-террейна обратно (нужно только в play). - try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {} - // Размораживаем world-matrix статичных моделей — в редакторе - // пользователь может двигать их через гизмо. - try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {} - try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {} - // Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается) - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - const m = data.mesh; - if (m && m._kubikonPrimCulled === true) { - m.setEnabled(data.visible !== false); - m._kubikonPrimCulled = false; - } - } - } - - // Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а, - // чтобы скрипты не успели потрогать player в момент disposal. - if (this.gameRuntime) { - this.gameRuntime.stop(); - this.gameRuntime = null; - } - - // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) - if (this.gdLevelManager) { - this.gdLevelManager.stop(); - } - // Этап G1: убрать skybox/параллакс (откатывает fog/clearColor) - if (this.gdSkybox) { - try { this.gdSkybox.dispose(); } catch (e) {} - this.gdSkybox = null; - } - // Этап G2: убрать декоративную траву + neon-edge - if (this.gdGroundSkin) { - try { this.gdGroundSkin.dispose(); } catch (e) {} - this.gdGroundSkin = null; - } - // Этап G3: убрать кастомные шипы, вернуть оригинальные конусы - if (this.gdSpikes) { - try { this.gdSpikes.dispose(); } catch (e) {} - this.gdSpikes = null; - } - // Этап G4: убрать стартовую арку - if (this.gdStartArch) { - try { this.gdStartArch.dispose(); } catch (e) {} - this.gdStartArch = null; - } - // Этап G5: убрать финиш-ворота - if (this.gdFinish) { - try { this.gdFinish.dispose(); } catch (e) {} - this.gdFinish = null; - } - // Этап G6: убрать деревья/кусты - if (this.gdForest) { - try { this.gdForest.dispose(); } catch (e) {} - this.gdForest = null; - } - // Этап G7: снять эффекты с куба игрока - if (this.gdPlayerCube) { - try { this.gdPlayerCube.dispose(); } catch (e) {} - this.gdPlayerCube = null; - } - // Этап G8: trail-частицы - if (this.gdPlayerTrail) { - try { this.gdPlayerTrail.dispose(); } catch (e) {} - this.gdPlayerTrail = null; - } - // Этап G9: пост-обработка (bloom/vignette/освещение) - if (this.gdPostFx) { - try { this.gdPostFx.dispose(); } catch (e) {} - this.gdPostFx = null; - } - - // Выключаем оружие - if (this.weapons) { - this.weapons.stop(); - } - // Выключаем зомби и спавнеры - if (this.spawnerManager) this.spawnerManager.stop(); - if (this.zombieManager) this.zombieManager.stop(); - // Выключаем NPC (удаляет их модели и UI). - if (this.npcManager) this.npcManager.stop(); - // Выключаем связи объектов. - if (this.constraintManager) this.constraintManager.stop(); - // Выключаем лучи и следы. - if (this.beamManager) this.beamManager.stop(); - // Выключаем 3D-звук (останавливает активные звуки). - if (this.soundManager) this.soundManager.stop(); - - if (this.player) { - this.player.stop(); - this.player = null; - } - - // Возвращаем визуальный маркер спавна - this._setSpawnMarkerVisible(true); - this.primitiveManager?.setTriggersVisible(true); - - // Останавливаем физику и возвращаем объекты на исходные позиции - this.dynamics?.stop(); - this._restoreDynamicObjects(); - // Полный откат сцены: пересоздаём примитивы и модели из снимка — - // возвращаются удалённые скриптом объекты, откатываются цвет/ - // видимость/коллизия/повороты, исчезают заспавненные объекты. - this._restoreFullScene(); - - // Останавливаем фоновый звук - this.audioManager?.stop(); - - // Восстанавливаем редактор-камеру - const snap = this._editorCameraSnapshot; - // Создаём новую UniversalCamera-редактор (наша старая была уничтожена когда - // PlayerController сделал scene.activeCamera = playerCamera). - // На самом деле она НЕ уничтожилась — мы просто переключали activeCamera. - // Возвращаем её обратно. - this.scene.activeCamera = this.camera; - if (snap) { - this.camera.position = snap.position; - this.camera.rotation = snap.rotation; - } - this._editorCameraSnapshot = null; - } - - dispose() { - if (this._resizeHandler) { - window.removeEventListener('resize', this._resizeHandler); - this._resizeHandler = null; - } - if (this._ro) { - this._ro.disconnect(); - this._ro = null; - } - for (const { target, type, fn, opts } of this._listeners) { - target.removeEventListener(type, fn, opts); - } - this._listeners = []; - if (this.player) { - this.player.stop(); - this.player = null; - } - if (this.history) { - this.history.dispose(); - this.history = null; - } - if (this._gizmo) { - try { this._gizmo.dispose(); } catch (e) { /* ignore */ } - this._gizmo = null; - } - if (this._gizmoLayer) { - try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } - this._gizmoLayer = null; - } - if (this.selection) { - this.selection.dispose(); - this.selection = null; - } - if (this.blockManager) { - this.blockManager.dispose(); - this.blockManager = null; - } - if (this.modelManager) { - this.modelManager.dispose(); - this.modelManager = null; - } - if (this.primitiveManager) { - this.primitiveManager.dispose(); - this.primitiveManager = null; - } - if (this.folderManager) { - this.folderManager.dispose(); - this.folderManager = null; - } - if (this.guiManager) { - this.guiManager.clear(); - this.guiManager = null; - } - if (this.inventory) { - this.inventory.clear(); - this.inventory = null; - } - if (this.dynamics) { - this.dynamics.dispose(); - this.dynamics = null; - } - if (this.audioManager) { - this.audioManager.dispose(); - this.audioManager = null; - } - if (this.assetManager) { - this.assetManager.dispose(); - this.assetManager = null; - } - if (this.soundLibrary) { - this.soundLibrary.dispose(); - this.soundLibrary = null; - } - if (this.glbLibrary) { - this.glbLibrary.dispose(); - this.glbLibrary = null; - } - this.environment = null; - this.physics = null; - if (this.scene) { - this.scene.dispose(); - this.scene = null; - } - if (this.engine) { - this.engine.dispose(); - this.engine = null; - } - } -} +/** + * BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией. + * + * Управление камерой (как в Roblox Studio): + * - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси) + * - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда) + * - ПКМ + Q/E : вниз/вверх по миру + * - ПКМ + Shift : ускоренный полёт (×2.5) + * - Колесо : zoom (приближение по оси взгляда) + * - Средняя кнопка drag : pan (сдвиг параллельно экрану) + * - F : фокус на (0,0,0) — будет на выбранный объект позже + * + * Используем UniversalCamera + ручной обработчик мыши/клавиш для точной + * имитации Roblox-controls (стандартные attachControl делают не то что нужно). + * + * Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже. + */ +import { + Engine, + Scene, + UniversalCamera, + Vector3, + Color3, + Color4, + HemisphericLight, + DirectionalLight, + ShadowGenerator, + CascadedShadowGenerator, + SSAORenderingPipeline, + MeshBuilder, + StandardMaterial, + DynamicTexture, + UtilityLayerRenderer, + TransformNode, + ParticleSystem, + Texture, + Ray, + Tools as BabylonTools, +} from '@babylonjs/core'; +import { BlockManager } from './BlockManager'; +import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; +// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. +// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy +// TerrainManager как shadow-копия — для замеров статистики чанков и +// готовности к Этапу 2 (greedy meshing). +import { VoxelWorld } from './voxel/VoxelWorld'; +import { VoxelRenderer } from './voxel/VoxelRenderer'; +import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator'; +// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава). +import { DecoManager } from './DecoManager'; +import { GRASS_MODELS_POOL } from './DecoModels'; +import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder'; +import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; +import { ModelManager } from './ModelManager'; +import { PrimitiveManager } from './PrimitiveManager'; +import { BillboardUiManager } from './BillboardUiManager'; +import { getPrimitiveType } from './PrimitiveTypes'; +import { FolderManager } from './FolderManager'; +import { GuiManager } from './GuiManager'; +import { ModalManager } from './ModalManager'; +import { InventoryManager } from './InventoryManager'; +import { WeaponSystem } from './WeaponSystem'; +import { ZombieManager } from './ZombieManager'; +import { NpcManager } from './NpcManager'; +import { ConstraintManager } from './ConstraintManager'; +import { BeamManager } from './BeamManager'; +import { PlacementManager } from './PlacementManager'; +import { ShopInventoryUi } from './ShopInventoryUi'; +import { LoadingScreenOverlay } from './LoadingScreenOverlay'; +import { VehicleManager } from './VehicleManager'; +import { VehicleHud } from './VehicleHud'; +import { ZombieSpawnerManager } from './ZombieSpawnerManager'; +import { DynamicsManager } from './DynamicsManager'; +import { Environment } from './Environment'; +import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; +import { GameAudioManager } from './GameAudioManager'; +import { AssetManager } from './AssetManager'; +import { SoundLibrary } from './SoundLibrary'; +import { SoundManager } from './SoundManager'; +import { GlbLibrary } from './GlbLibrary'; +import { GdLevelManager } from './GdLevelManager'; +import { GdSkybox } from './GdSkybox'; +import { GdGroundSkin } from './GdGroundSkin'; +import { GdSpikes } from './GdSpikes'; +import { GdStartArch } from './GdStartArch'; +import { GdPortalArch } from './GdPortalArch'; +import { GdDiamond } from './GdDiamond'; +import { GdPlayerModeSkin } from './GdPlayerModeSkin'; +import { GdFinish } from './GdFinish'; +import { GdForest } from './GdForest'; +import { GdPlayerCube } from './GdPlayerCube'; +import { GdPlayerTrail } from './GdPlayerTrail'; +import { GdPostFx } from './GdPostFx'; +import { PhysicsAABB } from './PhysicsAABB'; +import { PlayerController } from './PlayerController'; +import { SelectionManager } from './SelectionManager'; +import { GizmoController } from './GizmoController'; +import { HistoryManager } from './HistoryManager'; +import { GameRuntime } from './GameRuntime'; +import { attachConsoleHook, devlogReset } from './devlog'; +import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh'; +import { VoxelGrid } from './terrain/VoxelGrid'; +import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain'; +import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid'; +import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager'; + +export class BabylonScene { + /** + * @param {HTMLCanvasElement} canvas — DOM-элемент для рендера + */ + constructor(canvas) { + // DevLog: на localhost подключаем перехват console.* для записи в файл + // на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude + // возможность читать свежие логи без копипасты вручную. + try { + devlogReset(); + attachConsoleHook(); + } catch (e) {} + this.canvas = canvas; + this.engine = null; + this.scene = null; + this.camera = null; + + // Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы + // работало на русской раскладке: KeyW не зависит от языка ввода. + this._codes = new Set(); + this._shiftDown = false; + this._isRotating = false; // ПКМ зажата → крутим камеру + this._isPanning = false; // СКМ зажата → pan + this._lastMouseX = 0; + this._lastMouseY = 0; + + // Параметры + this.MOVE_SPEED = 12; // юнитов/секунду при WASD + this.SHIFT_MULTIPLIER = 2.5; + this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель + this.ZOOM_SPEED = 1.5; + this.PAN_SENSITIVITY = 0.025; + + // Состояние редактора блоков + this.blockManager = null; + this.modelManager = null; + this.primitiveManager = null; + this.folderManager = null; + this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image) + this.inventory = null; // инвентарь игрока (9 слотов hot-bar) + this.weapons = null; // система оружия (создаётся при enterPlayMode) + this.zombieManager = null; // AI зомби (создаётся при enterPlayMode) + this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) + this.constraintManager = null; // связи объектов (Фаза 5, Constraints) + this.beamManager = null; // лучи и следы (Фаза 5.2) + // Placement mode (задача 11) — фича-парность со студией. + this.placementManager = null; + this.shopInventoryUi = null; + this.vehicleManager = null; // задача 14 + this.vehicleHud = null; + this._VehicleHudClass = VehicleHud; + this._PlacementManagerClass = PlacementManager; + this._ShopInventoryUiClass = ShopInventoryUi; + // Экран загрузки (задача 12). + this.loadingScreen = null; + this._LoadingScreenOverlayClass = LoadingScreenOverlay; + this._loadingConfig = null; + this._mainMenuConfig = null; // задача 13 + this._projectThumbnail = null; + this.spawnerManager = null; // спавнеры зомби + this.environment = null; + this.audioManager = null; + this.assetManager = null; // библиотека пользовательских картинок + this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5) + this.soundManager = null; // 3D-воспроизведение звука (Play-only) + this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8) + this.selection = null; // SelectionManager + // Тач-режим (мобилки/планшеты) — выставляется снаружи через + // setTouchMode() ДО enterPlayMode. Влияет на PlayerController. + this._touchMode = false; + this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase' + this._activeBlockType = 'grass'; + this._activeModelType = null; + this._activePrimitiveType = 'cube'; + this._ghostMesh = null; + this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°) + this._gizmo = null; + this._gizmoLayer = null; + this._gizmoDragging = false; // флаг что идёт drag гизмо + this._isDragPlacing = false; // флаг drag-постановки/удаления блоков + this._isTerrainBrushing = false; // флаг drag-кисти террейна + this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять + this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды + this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком + this._dragLockValue = 0; // значение по фиксированной оси + + // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) + this._spawnPoint = { x: 0, y: 5, z: 0 }; + // Модель персонажа для режима Play. + // Дефолт — R15-скин bacon-hair (классический Roblox-вид). + // 'skin_*' грузится из characters//body.glb (R15-скелет), + // 'character-*' — старые Kenney-модели. + this._playerModelType = 'skin_bacon-hair'; + // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. + // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). + this._worldHalf = 40; + // Видимость пола (можно «удалить» — пол исчезнет визуально и из физики) + this._floorEnabled = true; + // Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше) + this._jumpPowerMul = 1; + // Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен. + this._crosshair = 'none'; + + // Скрипты пользователя (массив { id, code, target? }). + // На этапе 2.1 — только один глобальный «scene script», без UI редактирования. + // Хранится в проекте через serialize/loadFromState. + this._scripts = []; + this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode + + // Режим Play + this.player = null; // PlayerController когда играем + this.physics = null; // PhysicsAABB + this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры + this._isPlaying = false; + + // Drag-detection: чтобы не ставить блок при rotate (mouseup без movement + // = клик; с movement = drag). + this._mouseDownTime = 0; + this._mouseDownX = 0; + this._mouseDownY = 0; + this._mouseDownButton = -1; + + // Слушатели — храним чтобы корректно отписаться + this._listeners = []; + this._resizeHandler = null; + } + + init() { + // На тач-устройствах сразу отключаем anti-aliasing — это даёт + // заметный буст FPS на мобилах. Anti-aliasing полезен только на + // больших мониторах с низким DPR. + const isTouchDevice = (typeof window !== 'undefined') && ( + 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0 + ); + const isSmallScreen = (typeof window !== 'undefined') + && window.innerWidth <= 1024; + const useAA = !(isTouchDevice && isSmallScreen); + // MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций + // Можно принудительно отключить через ?desktop=1 в URL (для отладки). + const forceDesktop = (typeof window !== 'undefined') + && new URLSearchParams(window.location.search).has('desktop'); + this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop; + this.engine = new Engine(this.canvas, useAA, { + preserveDrawingBuffer: true, + stencil: true, + // Parallel shader compile — критично для устранения фризов при + // повороте камеры. Когда новый material попадает во frustum, + // Babylon без этого синхронно компилит shader и блокирует UI. + // С параллельным compile рендер использует fallback shader и + // переключается на оптимизированный когда тот готов. + useHighPrecisionFloats: false, + powerPreference: 'high-performance', + }, true); + // MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН. + // Логи показали что узкое место — CPU (draw calls растут до 60k), + // а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS. + + // PERF-METRICS: счётчики для perf-overlay. Накопительно за окно + // сэмплинга 5сек, потом overlay читает и сбрасывает. + this._perfMetrics = { + render_ms_sum: 0, render_count: 0, + physics_ms_sum: 0, physics_count: 0, + script_ms_sum: 0, script_count: 0, + // Замер idle-времени между концом prev-render и началом next-render. + // Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток + // ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест). + idle_ms_sum: 0, idle_count: 0, + _lastRenderEnd: 0, + }; + + this.scene = new Scene(this.engine); + this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0); + // ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1), + // но без scene.ambientColor != 0 это умножается на 0 и боковые грани + // вокселей остаются чёрными (направленный свет не освещает их с + // sunset preset). (0.3,0.3,0.3) даёт мягкое всестороннее освещение, + // не пересвечивает существующие сцены. + this.scene.ambientColor = new Color3(0.3, 0.3, 0.3); + // Глобальный хендл для отладки из консоли: window.__BS — это инстанс + // BabylonScene; window.__SC — Babylon scene; window.__ENG — engine. + // window.__BJS — набор Babylon-классов для dev-инструментов + // (@babylonjs/core модульный, window.BABYLON не существует) — + // им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js. + if (typeof window !== 'undefined') { + window.__BS = this; + window.__SC = this.scene; + window.__ENG = this.engine; + window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools }; + } + // ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь. + // Когда true — ставим свойства материала (emissiveColor/disableLighting/ + // alpha) у новых мешей (трейсеры выстрелов, debris при смерти, + // муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти + // свойства не применяются. Эффект: трейсер/дебрис создаются, но + // НЕ ВИДНЫ. Включать только локально вокруг массовых операций + // (если когда-то появится нужда), сразу выключая обратно. + // Skip pointer-move picking — не делаем raycast от мыши на каждый + // mousemove. Игроку важны клик и hover-через-canvas, а не каждый move. + this.scene.skipPointerMovePicking = true; + // Параллельная компиляция шейдеров — фоновая компиляция новых + // материалов без блокировки рендера (если поддержано WebGL2). + if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) { + try { + // Babylon 6+ — это просто флаг capability, выставляется + // автоматически при поддержке. Логируем для отладки. + // eslint-disable-next-line no-console + console.log('[BabylonScene] parallel shader compile:', + !!this.engine.getCaps().parallelShaderCompile); + } catch (e) {} + } + + // Возвращаем detachControl — наши mousedown-listeners на canvas с + // capture=true должны работать без вмешательства Babylon-pointerHandler. + // Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене. + this.scene.detachControl(); + + this._createCamera(); + this._createLights(); + this._createGroundGrid(); + this._createGhostBlock(); + this._createSpawnMarker(); + this._setupInputControls(); + + // Менеджеры объектов + this.blockManager = new BlockManager(this.scene); + // При создании нового proto-меша блока — сразу регистрируем его + // как shadow caster (если генератор уже создан). + this.blockManager.setOnProtoCreated((proto) => { + this.addShadowCaster(proto); + }); + + // Менеджер декораций — Этап 6 voxel-движка. + // Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий. + this.decoManager = new DecoManager(this.scene); + this.decoManager.setOnChange(() => { + if (this._onSceneChange) this._onSceneChange(); + }); + + // Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями. + // Использует thin-instances per материал, как BlockManager. + this.terrainManager = new TerrainManager(this.scene); + // ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая + // карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки + // на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows + // оставлен — тени от других объектов (моделей) показываются. + // this.terrainManager.setOnProtoCreated((proto) => { + // this.addShadowCaster(proto); + // }); + this.terrainManager.setOnChange(() => { + // Пометить сцену как изменённую — автосохранение подхватит. + // Имя коллбэка — _onSceneChange (то же что у blockManager/ + // modelManager/primitiveManager). Раньше тут было _onChange — + // несуществующее поле, из-за чего террейн не сохранялся + // автоматически. Только ручная кнопка «Сохранить» дёргает + // serialize() напрямую и попадала в БД. + if (this._onSceneChange) this._onSceneChange(); + }); + + // === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре === + // Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами, + // но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld= + // false) — только структура данных для замера chunk-статистики и + // подготовки к Этапу 2. + // window.__voxelWorldStats() — выведет в консоль текущую статистику. + // window.__voxelWorldRender(true/false) — переключит рендер на новый + // (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и + // поверх старых — для визуальной валидации. + this.voxelWorld = new VoxelWorld(); + this.voxelWorld.setOnChange(() => { + // Авто-rebuild dirty чанков при изменении (только если рендер включён) + if (this._voxelRenderEnabled && this.voxelRenderer) { + this.voxelRenderer.rebuildDirty(); + } + }); + this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); + this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); + /** Включить/выключить рендер VoxelWorld. По умолчанию false — только + * loadFromArray в VoxelWorld для статистики, без отображения. */ + this._voxelRenderEnabled = false; + /** Этап 4 streaming: рендерить только чанки в радиусе от камеры. + * false по умолчанию — рендерим все чанки (для маленьких карт). + * Включается через window.__voxelWorldStreaming(true, 64). */ + this._voxelStreamingEnabled = false; + this._voxelStreamingRadius = 64; // метров + this._voxelStreamingLastUpdate = 0; + this._voxelStreamingInterval = 250; // мс между проверками + if (typeof window !== 'undefined') { + window.__voxelWorldStats = () => { + const s = this.voxelWorld.stats(); + console.log('[VoxelWorld stats]', s); + return s; + }; + // Диагностика FPS bottleneck'ов на больших картах. + // Запускать в консоли когда лагает: window.__voxelPerfReport() + // Debug-команды для диагностики FPS-проблем. + // Запускать в консоли — увидим что реально жрёт CPU/GPU. + window.__toggleShadows = (on) => { + this.setShadowQuality(on === false ? 'off' : 'soft'); + console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON'); + }; + window.__togglePostProcess = (on) => { + if (this.scene && this.scene.postProcessRenderPipelineManager) { + // Babylon не умеет тривиально выключать pipeline, поэтому + // просто отключаем все pipelines + const enabled = on !== false; + if (this._postProcessPipelines) { + for (const p of this._postProcessPipelines) { + try { p.setEnabled(enabled); } catch (e) {} + } + } + } + console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON'); + }; + window.__toggleSceneOptim = (on) => { + // Глобальные оптимизации Babylon + const scn = this.scene; + if (on !== false) { + scn.freezeActiveMeshes(); + scn.skipFrustumClipping = true; + scn.blockfreeActiveMeshesAndRenderingGroups = true; + console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON'); + } else { + scn.unfreezeActiveMeshes(); + scn.skipFrustumClipping = false; + scn.blockfreeActiveMeshesAndRenderingGroups = false; + console.log('[Debug] scene optim: OFF'); + } + }; + window.__voxelPerfReport = () => { + const tm = this.terrainManager; + if (!tm) return console.warn('no terrainManager'); + const scn = this.scene; + const eng = scn.getEngine(); + const totalMeshes = scn.meshes.length; + let activeMeshes = 0; + let activeRegionMeshes = 0; + let activeDecoMeshes = 0; + for (const m of scn.meshes) { + if (m.isEnabled() && m.material) activeMeshes++; + } + if (tm._regionMeshes) { + for (const m of tm._regionMeshes.values()) { + if (m.isEnabled()) activeRegionMeshes++; + } + } + if (this.decoManager?._chunkMeshes) { + for (const colorMap of this.decoManager._chunkMeshes.values()) { + for (const m of colorMap.values()) { + if (m.isEnabled()) activeDecoMeshes++; + } + } + } + // FPS: Babylon engine.getFps() даёт усреднённый, instantaneous + // (1000/getDeltaTime) скачет хаотично из-за GC. + const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime()); + const instFps = 1000 / eng.getDeltaTime(); + // Подсчёт активных треугольников и draw calls. + // Babylon хранит sceneInstrumentation, но он opt-in. + // Считаем вручную из активных мешей. + let activeTriangles = 0; + let activeVertices = 0; + let activeDrawCalls = 0; + if (this.scene && this.scene.meshes) { + for (const m of this.scene.meshes) { + if (!m.isEnabled() || !m.material) continue; + // Frustum cull skip + const idxCount = m.getTotalIndices?.() ?? 0; + if (idxCount === 0) continue; + // thin-instances умножают + const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1; + activeTriangles += (idxCount / 3) * instCount; + activeVertices += (m.getTotalVertices?.() ?? 0) * instCount; + // 1 draw call на меш (multimat = +submeshes) + const subMeshes = m.subMeshes ? m.subMeshes.length : 1; + activeDrawCalls += subMeshes; + } + } + // Frame time: целевые значения + // 60 FPS = 16.6мс/кадр + // 30 FPS = 33.3мс/кадр + // 23 FPS = 43.5мс/кадр ← наша проблема + const frameMs = eng.getDeltaTime(); + // Visibility — Chrome даёт throttle до 20 FPS если таб неактивен + const docHidden = typeof document !== 'undefined' && document.hidden; + const winFocused = typeof document !== 'undefined' && document.hasFocus?.(); + // PERF-DIAG: где теряется время? + // render_ms — сколько занимает scene.render() (GPU + babylon) + // idle_ms — промежуток между концом render и началом + // следующего кадра (если велик — GPU-bound + // ИЛИ браузер throttle; если мал, а frame_ms + // большой — узкое место в нашем JS до render). + const pm = this._perfMetrics; + const renderMsAvg = pm && pm.render_count + ? (pm.render_ms_sum / pm.render_count) : 0; + const idleMsAvg = pm && pm.idle_count + ? (pm.idle_ms_sum / pm.idle_count) : 0; + // Сбрасываем накопители — следующий отчёт за свежий период. + if (pm) { + pm.render_ms_sum = 0; pm.render_count = 0; + pm.idle_ms_sum = 0; pm.idle_count = 0; + } + console.log('[PerfReport]', { + fps_stable: stableFps.toFixed(1), + fps_instant: instFps.toFixed(1), + frame_ms: frameMs.toFixed(1), + render_ms: renderMsAvg.toFixed(1), + idle_ms: idleMsAvg.toFixed(1), + isPlaying: this._isPlaying, + triangles_K: (activeTriangles / 1000).toFixed(0) + 'K', + drawCalls: activeDrawCalls, + tab_hidden: docHidden, + win_focused: winFocused, + voxelCount: tm.voxels?.size ?? 0, + sceneMeshes: totalMeshes, + activeMeshes, + regionMeshes: tm._regionMeshes?.size ?? 0, + activeRegionMeshes, + decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0, + activeDecoMeshes, + streamingRadius: this._terrainStreamingRadius, + // Новый TerrainMesh (Roblox-style, voxel) + tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0, + tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0, + tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0, + // Roblox Smooth Terrain + rt_chunks: this._robloxTerrain?.chunks?.size ?? 0, + rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0, + rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0, + }); + }; + this._decoMeshCount = () => { + let n = 0; + if (!this.decoManager?._chunkMeshes) return 0; + for (const m of this.decoManager._chunkMeshes.values()) n += m.size; + return n; + }; + // === LEAK DETECTOR (dev-only) === + // Если sceneMeshes растёт без явной причины — каждый snapshot + // запоминаем имена мешей, на след. snapshot печатаем НОВЫЕ. + // Точно покажет утечку: какие меши накапливаются. + // Использовать: window.__leakSnap(); потом подождать 5 сек, + // снова window.__leakSnap() — выведет diff. + let _leakLastNames = null; + window.__leakSnap = () => { + const names = this.scene.meshes.map(m => m.name || ''); + if (_leakLastNames === null) { + _leakLastNames = new Map(); + for (const n of names) _leakLastNames.set(n, (_leakLastNames.get(n) || 0) + 1); + console.log('[LeakSnap] baseline:', names.length, 'мешей. Подожди 5+ сек и зови __leakSnap() снова.'); + return; + } + const cur = new Map(); + for (const n of names) cur.set(n, (cur.get(n) || 0) + 1); + const diff = {}; + let totalDiff = 0; + for (const [n, c] of cur) { + const prev = _leakLastNames.get(n) || 0; + if (c > prev) { diff[n] = `+${c - prev} (теперь ${c})`; totalDiff += c - prev; } + } + for (const [n, c] of _leakLastNames) { + if (!cur.has(n)) { diff[n] = `-${c} (удалён)`; totalDiff -= c; } + } + console.log('[LeakSnap] diff:', totalDiff > 0 ? `+${totalDiff}` : totalDiff, diff); + _leakLastNames = cur; + }; + // Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog. + // Активируется автоматически на localhost. На прод не работает. + window.__perfMonitorStart = (interval = 2000) => { + if (window.__perfMonitorTimer) { + clearInterval(window.__perfMonitorTimer); + } + window.__perfMonitorTimer = setInterval(() => { + try { window.__voxelPerfReport?.(); } catch (e) {} + }, interval); + console.log(`[PerfMonitor] started, interval=${interval}ms`); + }; + window.__perfMonitorStop = () => { + if (window.__perfMonitorTimer) { + clearInterval(window.__perfMonitorTimer); + window.__perfMonitorTimer = null; + console.log('[PerfMonitor] stopped'); + } + }; + // Автостарт мониторинга на localhost — Claude читает devlog.txt + if (typeof window !== 'undefined' + && (window.location.hostname === 'localhost' + || window.location.hostname === '127.0.0.1')) { + setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000); + } + // === Тест нового TerrainMesh (Roblox/Minecraft-style) === + // + // Создаёт VoxelGrid и заполняет его holmistym ландшафтом из + // sin-волн. Рендерится через Greedy Meshing. + // Использование в DevTools: + // __terrainTest(64) — небольшая карта 64×16×64м + // __terrainTest(150) — большая 150×24×150м + // __terrainTest(250) — целевая 250×32×250м + window.__terrainTest = (sizeMeters = 64) => { + if (!this._terrainMesh) { + this._terrainMesh = new TerrainMesh(this.scene); + } + const tm = this._terrainMesh; + // Удалим старый legacy terrain — он перекрывает картинку + try { + if (this.terrainManager) this.terrainManager.clear(); + if (this.decoManager) this.decoManager.clear(); + } catch (e) {} + tm.disposeAll(); + + const t0 = performance.now(); + const sx = sizeMeters, sz = sizeMeters; + const sy = 32; + const grid = new VoxelGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + // Заполняем heightmap-картой: y = базовый + sin(x)*cos(z) + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const fx = (x - sx / 2) / sx; + const fz = (z - sz / 2) / sz; + const h = Math.floor( + 6 + Math.sin(fx * Math.PI * 3) * 4 + + Math.cos(fz * Math.PI * 4) * 3 + + Math.sin((fx + fz) * Math.PI * 6) * 2, + ); + for (let y = 0; y < h && y < sy; y++) { + let mat; + if (y === h - 1) mat = 'grass'; + else if (y >= h - 3) mat = 'dirt'; + else mat = 'rock'; + grid.set(x, y, z, mat); + } + } + } + const tFill = performance.now() - t0; + const solid = grid.countSolid(); + console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`); + + tm.loadFromGrid(grid); + + // Сразу материализуем ВСЕ chunks (для теста, не lazy) + const t1 = performance.now(); + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 }); + const tBuild = performance.now() - t1; + const tris = tm.getActiveTriangles(); + console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`); + console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`); + }; + + // Удалить тестовый terrain mesh + window.__terrainTestClear = () => { + if (this._terrainMesh) { + this._terrainMesh.disposeAll(); + console.log('[TerrainTest] cleared'); + } + }; + + // ============================================================ + // Roblox-style Smooth Terrain test + // ============================================================ + // + // Использование в DevTools: + // __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м + // __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth) + // + // Создаёт holmistyy ландшафт через density-функцию и рендерит + // через Surface Nets. Проверка что архитектура работает. + window.__robloxTest = async (gridSize = 50, userParams = null) => { + if (!this._robloxTerrain) { + this._robloxTerrain = new RobloxTerrain(this.scene); + // Подключить к физике — иначе игрок проваливается в smooth terrain + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + } + const rt = this._robloxTerrain; + try { + if (this.terrainManager) this.terrainManager.clear(); + if (this.decoManager) this.decoManager.clear(); + if (this.voxelWorld) { + const layer = this.voxelWorld.getLayer?.('terrain'); + if (layer && layer.clear) layer.clear(); + } + this._terrainStreamingEnabled = false; + } catch (e) {} + rt.disposeAll(); + + const t0 = performance.now(); + const sx = gridSize, sz = gridSize; + const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор) + const grid = new RobloxDensityGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + + // === Используем тот же WorldGenerator что и voxel-генератор === + // 1 smooth-cell = 4м = 16 voxel-units. + // sampleHeight возвращает высоту в voxel-units (0.25м). + // sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}. + // + // userParams приходит из UI (TerrainGenPanel buildParams). + // Если null — берём дефолтные. + const params = userParams + ? JSON.parse(JSON.stringify(userParams)) + : JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS)); + console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`); + const gen = new WorldGenerator(params); + + // Маппинг материалов voxel-генератора → smooth (DensityGrid + // поддерживает только grass/rock/sand/snow). + // dirt → grass, остальные пропускаются. + const matMap = (m) => { + if (m === 'dirt') return 'grass'; + if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m; + return 'grass'; + }; + + // Шаг 1: heightmap + biome для каждой smooth-cell. + // x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16 + const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16 + const heightMap = new Float32Array(sx * sz); + const topMats = new Array(sx * sz); + const softMats = new Array(sx * sz); + const hardMats = new Array(sx * sz); + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2; + const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2; + const hVoxels = gen.sampleHeight(vx, vz); + const biome = gen.sampleBiome(vx, vz); + const hCells = hVoxels / CELL_VOXELS; + heightMap[z * sx + x] = hCells; + topMats[z * sx + x] = matMap(biome.topMaterial); + softMats[z * sx + x] = matMap(biome.softMaterial); + hardMats[z * sx + x] = matMap(biome.hardMaterial); + } + } + + // Шаг 2: density + материалы. + // Топ-слой: topMaterial биома. + // Средний (1..3 cells вглубь): softMaterial. + // Глубокий (>3 cells): hardMaterial. + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const h = heightMap[z * sx + x]; + const topMat = topMats[z * sx + x]; + const softMat = softMats[z * sx + x]; + const hardMat = hardMats[z * sx + x]; + const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h; + const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h; + const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h; + const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h; + const slope = Math.max( + Math.abs(h - h1), Math.abs(h - h2), + Math.abs(h - h3), Math.abs(h - h4), + ); + // На очень крутых обрывах (>3 cells = 12м перепад) — + // обнажение rock даже на травяных склонах. + const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow'; + + for (let y = 0; y < sy; y++) { + const delta = h - y; + let densityF; + if (delta > 2) densityF = 1; + else if (delta < -2) densityF = 0; + else densityF = (delta + 2) / 4; + const density = (densityF * 255) | 0; + if (density > 0) { + let mat; + if (useRockSlope) mat = 'rock'; + else if (delta < 1) mat = topMat; + else if (delta < 3) mat = softMat; + else mat = hardMat; + grid.set(x, y, z, density, mat); + } + } + } + } + const tFill = performance.now() - t0; + console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`); + + rt.loadFromGrid(grid); + + // Материализуем ВСЕ chunks сразу для теста. + const t1 = performance.now(); + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); + const tBuild = performance.now() - t1; + const stats = rt.getStats(); + console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`); + + // Мини-карта для свежесгенерированного гладкого ландшафта. + this._setupMinimapForRobloxTerrain(); + + // === Авто-спавн над поверхностью === + // Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0. + // Spawn = top_y + 2м над ней. + const CS = 4; // CELL_SIZE + const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell + const cellZ0 = 0 - grid.origin.z; + let topCellY = -1; + for (let cy = sy - 1; cy >= 0; cy--) { + if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; } + } + if (topCellY >= 0) { + const surfaceY = (grid.origin.y + topCellY + 1) * CS; + this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 }; + this._updateSpawnMarker?.(); + console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`); + } + + // Отключаем baseplate-пол — иначе он закрывает обзор и + // создаёт коллизии под smooth-ландшафтом. + try { this.setFloorEnabled(false); } catch (e) {} + + // === Декорации (цветы / трава / грибы) === + // Размещаем 3D-модели Kenney Nature Kit через thin-instances. + // Используем те же sampleHeight/sampleBiome из WorldGenerator + // что и для terrain — биомы определят какие декорации куда идут. + const decoOpts = userParams?.smoothDeco ?? { + flowersDensity: 0.025, + grassDensity: 0.10, + treesDensity: 0.4, + }; + // Сохраняем параметры для сериализации (при load воссоздадим) + this._smoothDecoParams = { + flowersDensity: decoOpts.flowersDensity, + grassDensity: decoOpts.grassDensity, + treesDensity: decoOpts.treesDensity ?? 0.4, + seed: params.seed || 1337, + bbox: { + minX: -(sx * CS) / 2, maxX: (sx * CS) / 2, + minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2, + }, + // Параметры WorldGenerator нужны для воссоздания biome-маппинга + genParams: params, + }; + if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) { + try { + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + const tDeco0 = performance.now(); + await this._smoothDecoManager.loadAll(); + const tDecoLoad = performance.now() - tDeco0; + // bbox в мировых координатах (метры) + const halfMeters = (sx * CS) / 2; + const bbox = { + minX: -halfMeters, maxX: halfMeters, + minZ: -halfMeters, maxZ: halfMeters, + }; + // Хелпер для surface raycast (использует физику) + const sampleSurfaceY = (x, z) => { + if (!this.physics?._sampleRobloxSurface) return null; + return this.physics._sampleRobloxSurface(x, z); + }; + const sampleBiomeId = (x, z) => { + // x,z в метрах → voxel-units (×4) + const vx = x * 4; + const vz = z * 4; + const biome = gen.sampleBiome(vx, vz); + return biome?.id; + }; + const tDeco1 = performance.now(); + const r = this._smoothDecoManager.placeDecorations({ + sampleSurfaceY, sampleBiomeId, bbox, + densityFlowers: decoOpts.flowersDensity, + densityGrass: decoOpts.grassDensity, + densityTrees: decoOpts.treesDensity ?? 0, + seed: params.seed || 1337, + }); + const tDecoPlace = performance.now() - tDeco1; + console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`); + // Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы. + if (this.physics?.setSmoothDecoTrees && r.treeColliders) { + this.physics.setSmoothDecoTrees(r.treeColliders); + } + } catch (e) { + console.error('[RobloxTest] decorations failed:', e); + } + } + + // Перемещаем редактор-камеру повыше чтобы видеть весь рельеф + if (this.camera && topCellY >= 0) { + const surfaceY = (grid.origin.y + topCellY + 1) * CS; + this.camera.position.x = sx * CS * 0.3; + this.camera.position.y = surfaceY + 30; + this.camera.position.z = sz * CS * 0.3; + this.camera.setTarget?.(new Vector3(0, surfaceY, 0)); + } + }; + + window.__robloxTestClear = () => this.clearRobloxTerrain(); + + // Этап 7a: процедурный генератор + // window.__voxelGenerate({size:160, params:{...}}) — генерирует + // террейн в bbox [-size..+size] и заменяет существующий terrain. + window.__voxelGenerate = async (opts = {}) => { + // ГЛОБАЛЬНЫЙ лок (на window, не на this!). + // Без него при HMR (hot module reload в dev) каждая копия + // BabylonScene имеет свой this._voxelGenerating, и команда + // в консоли вызывает все копии параллельно. + // window.__voxelGenLock виден ВСЕМ копиям сцены. + if (window.__voxelGenLock) { + console.warn('[VoxelGen] already running, ignoring duplicate call'); + return null; + } + window.__voxelGenLock = true; + // size — half-size в voxel-units (0.25м/voxel). + // Картa = size × 2 × 0.25м. + // size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию + // size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ + // Жёсткий лимит — 200 (карта 100×100м максимум). + // Для больших карт используйте Roblox-style smooth terrain. + try { + const MAX_SIZE = 200; + let size = opts.size ?? 160; + if (size > MAX_SIZE) { + console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`); + size = MAX_SIZE; + } + const params = opts.params ?? DEFAULT_GENERATOR_PARAMS; + // Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams) + window.__lastGenParams = params; + window.__lastGenSize = size; + console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`); + + // ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно + // (MultiCube для grass:top/side/bottom работает, текстуры + // настроены корректно). VoxelWorld остаётся как shadow-copy + // для RLE-сжатия в БД, но не для рендера. + // + // VoxelRenderer (новый) пока что выключен — он показывал + // серую кашу из-за проблем с MultiCube. + this._voxelRenderEnabled = false; + if (this.voxelRenderer) { + this.voxelRenderer.dispose(); + this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); + this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); + } + + // Progress callback — UI подхватывает через window.__voxelGenProgress. + const onProgress = (done, total, phase) => { + const pct = Math.min(100, Math.round((done / total) * 100)); + if (window.__voxelGenProgress) { + try { window.__voxelGenProgress(pct, phase); } catch (e) {} + } + }; + onProgress(0, 100, 'starting'); + + // Этап C оптимизации: генерация в Web Worker'е. + // Main thread не блокируется, UI отзывчив, progress-bar плавный. + const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker'); + const workerUrl = getTerrainGenWorkerUrl(); + const worker = new Worker(workerUrl); + + let voxels, decorations, treesPlaced, statsTimeMs; + try { + await new Promise((resolve, reject) => { + worker.onmessage = (e) => { + const m = e.data; + if (m.type === 'progress') { + onProgress(m.done, m.total, m.phase); + } else if (m.type === 'done') { + voxels = m.voxels; + decorations = m.decorations; + treesPlaced = m.treesPlaced; + statsTimeMs = m.timeMs; + resolve(); + } else if (m.type === 'error') { + reject(new Error('[Worker] ' + m.message)); + } + }; + worker.onerror = (err) => { + reject(new Error('[Worker] crash: ' + err.message)); + }; + worker.postMessage({ + type: 'generate', + params, + bbox: { x0: -size, z0: -size, x1: size, z1: size }, + }); + }); + } finally { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + } + console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`); + onProgress(95, 100, 'render'); + await new Promise(r => setTimeout(r, 0)); + + // Заливаем в legacy TerrainManager (он отрендерит правильно). + // Очищаем TerrainMesh если был — на новых генерациях не нужен. + if (this._terrainMesh) { + try { this._terrainMesh.disposeAll(); } catch (e) {} + } + if (this.terrainManager) { + this.terrainManager.clear(); + if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') { + await this.terrainManager.loadFromArray(voxels); + } else { + this.terrainManager.loadFromArray(voxels); + } + console.log(`[VoxelGen] loaded into legacy TerrainManager`); + + // Также пишем в VoxelWorld для RLE-сжатия в БД + try { + const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25); + vwLayer.clear(); + vwLayer.loadFromArray(voxels); + } catch (e) { /* ignore */ } + + // АВТОВКЛЮЧЕНИЕ STREAMING для больших карт. + const regionCount = this.terrainManager.getRegionCount?.() ?? 0; + if (regionCount > 0) { + this._terrainStreamingEnabled = true; + // Адаптивный radius по количеству вокселей: чем больше + // карта, тем меньше radius (иначе слишком много рендерится). + // <300K voxels → 40м (норма для маленьких карт) + // 300K-1M → 36м + // 1M-2M → 32м + // >2M → 28м (очень большие) + const vc = voxels.length; + let radius = 40; + if (vc > 2_000_000) radius = 28; + else if (vc > 1_000_000) radius = 32; + else if (vc > 300_000) radius = 36; + this._terrainStreamingRadius = radius; + this._terrainStreamingLastUpdate = 0; + const cam = this.camera; + if (cam) { + const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius); + console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`); + } + } else { + this._terrainStreamingEnabled = false; + } + } + + // Этап 6: загружаем decorations (мини-воксельные цветы/грибы). + if (this.decoManager && decorations) { + this.decoManager.loadFromArray(decorations); + // Этап D: первый pass LOD streaming для деко. + // maxBuild=2 — деко достроятся плавно через updateStreaming. + if (this.camera && this.decoManager.updateStreaming) { + const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); + this.decoManager.updateStreaming( + this.camera.position.x, this.camera.position.z, decoRadius, + { maxBuild: 2 }, + ); + } + } + + onProgress(100, 100, 'done'); + return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs }; + } finally { + // Снять глобальный лок ОБЯЗАТЕЛЬНО. + window.__voxelGenLock = false; + } + }; + // Готовые пресеты для быстрого теста + window.__voxelPresets = { + default: DEFAULT_GENERATOR_PARAMS, + mountains: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 }, + }, + flat: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, + }, + islands: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 }, + }, + forest: { + ...DEFAULT_GENERATOR_PARAMS, + biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b => + b.id === 'plain' || b.id === 'forest' + ? { ...b, features: { ...b.features, trees: 1.5 } } + : b, + ), + }, + }; + + // Этап 4: streaming контроль + window.__voxelWorldStreaming = (enabled, radius = 64) => { + this._voxelStreamingEnabled = !!enabled; + this._voxelStreamingRadius = radius; + if (!this._voxelRenderEnabled) { + console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)'); + return; + } + if (!this._voxelStreamingEnabled) { + // Сброс: загрузить все чанки обратно + this.voxelRenderer.rebuildAll(); + console.log('[VoxelWorld] streaming OFF — все чанки видимы'); + return; + } + // Стартовый update вокруг камеры + const cam = this.camera; + const center = { x: cam.position.x, z: cam.position.z }; + const r = this.voxelRenderer.updateStreaming(center, radius); + console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); + }; + // Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64 + window.__voxelWorldBenchmarkRLE = () => { + const t0 = performance.now(); + const rleData = this.voxelWorld.serialize(); + const t1 = performance.now(); + const rleJson = JSON.stringify(rleData); + const t2 = performance.now(); + const rleBytes = new Blob([rleJson]).size; + + // Legacy формат для сравнения — массив {x,y,z,m} + const legacyVoxels = []; + const layer = this.voxelWorld.getLayer('terrain'); + if (layer) { + for (const ch of layer.chunks.values()) { + const ox = ch.voxelOriginX(); + const oy = ch.voxelOriginY(); + const oz = ch.voxelOriginZ(); + for (let i = 0; i < 32768; i++) { + const idx = ch.data[i]; + if (idx === 0) continue; + const m = layer.matIdxToId(idx); + const lx = i % 32; + const lz = ((i / 32) | 0) % 32; + const ly = (i / 1024) | 0; + legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m }); + } + } + } + const t3 = performance.now(); + const legacyJson = JSON.stringify(legacyVoxels); + const legacyBytes = new Blob([legacyJson]).size; + const t4 = performance.now(); + + const ratio = (legacyBytes / rleBytes).toFixed(1); + const sizes = this.voxelWorld.measureSize(); + console.log('[RLE Benchmark]'); + console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`); + console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`); + console.log(` Уменьшение: ${ratio}× меньше`); + console.log(' Подробно:', sizes); + return { legacy: legacyBytes, rle: rleBytes, ratio }; + }; + + // Чистый benchmark mesh-build без создания Babylon-meshей. Это + // показывает скорость алгоритма greedy в отрыве от GPU. + window.__voxelWorldBenchmark = async () => { + const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher'); + const { buildChunkGeometry } = await import('./voxel/ChunkMesher'); + const layer = this.voxelWorld.getLayer('terrain'); + if (!layer) { console.warn('no terrain layer'); return; } + const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); + + // Surface culling (Этап 1) + let totalFacesNonGreedy = 0; + const t1 = performance.now(); + for (const ch of layer.chunks.values()) { + const r = buildChunkGeometry(ch, layer, neighborMatIdx); + totalFacesNonGreedy += r.totalFaces; + } + const dt1 = performance.now() - t1; + + // Greedy (Этап 2) + let totalFacesGreedy = 0; + const t2 = performance.now(); + for (const ch of layer.chunks.values()) { + const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx); + totalFacesGreedy += r.totalFaces; + } + const dt2 = performance.now() - t2; + + const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1); + console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`); + console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`); + return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 }, + greedy: { quads: totalFacesGreedy, ms: dt2 }, + reduction: `${reduction}%` }; + }; + window.__voxelWorldRender = (enabled) => { + this._voxelRenderEnabled = !!enabled; + if (this._voxelRenderEnabled) { + // Прячем legacy TerrainManager mesh'и + for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { + proto.setEnabled(false); + } + // Если streaming ON — грузим только видимые чанки + // (rebuildAll потом бы их сразу half-выгрузил, лишняя работа). + if (this._voxelStreamingEnabled && this.camera) { + const cam = this.camera; + const r = this.voxelRenderer.updateStreaming( + { x: cam.position.x, z: cam.position.z }, + this._voxelStreamingRadius, + ); + console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); + } else { + this.voxelRenderer.rebuildAll(); + console.log('[VoxelWorld] render ENABLED, legacy hidden'); + } + } else { + // Показываем legacy обратно, скрываем новый + for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { + proto.setEnabled(true); + } + this.voxelRenderer.dispose(); + console.log('[VoxelWorld] render DISABLED, legacy restored'); + } + }; + } + // Состояние brush'а ландшафта, обновляется из TerrainPanel. + // tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten' + // material — id из TERRAIN_MATERIALS + // brushSize — радиус кисти в voxel'ах + // strength — 1..100 + // shape — 'sphere'|'cube'|'cylinder' + this._terrainBrush = { + tool: 'sculpt', + material: 'grass', + brushSize: 4, + strength: 50, + shape: 'sphere', + // terrainMode: 'voxel' (по умолчанию) | 'smooth'. + // В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes, + // в voxel — TerrainManager.voxels (как раньше). + terrainMode: 'voxel', + }; + // Полупрозрачный preview-меш под курсором (показывает где будет кисть) + this._terrainBrushPreview = null; + this.modelManager = new ModelManager(this.scene, this); + this.modelManager.setScene3D(this); + // Делаем ModelManager доступным через scene — MultiplayerSync.js + // подхватывает его для shared-кэша GLB-прототипов. + this.scene._kubikonModelManager = this.modelManager; + + // Этап 5 редактора моделей: менеджер пользовательских voxel-моделей. + // API подключается отдельно через setUserModelsApi (см. ниже), + // потому что Kubikon3DService импортируется через ES-modules. + this.userModelManager = new UserModelManager(this.scene); + // Глобальная функция для отладки: window.__kubikonDebugColliders() + // выводит в консоль все коллайдеры моделей и примитивов. + if (typeof window !== 'undefined') { + window.__kubikonDebugColliders = () => { + const out = []; + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + const a = data.localAABB; + if (a) { + const w = (a.maxX - a.minX).toFixed(2); + const h = (a.maxY - a.minY).toFixed(2); + const d = (a.maxZ - a.minZ).toFixed(2); + out.push({ + kind: 'model', id: data.modelTypeId, + pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], + size: [w, h, d], + canCollide: data.canCollide, + }); + } + } + } + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + out.push({ + kind: 'primitive', type: data.type, + pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], + size: [data.sx, data.sy, data.sz], + canCollide: data.canCollide, + }); + } + } + console.table(out); + return out; + }; + } + this.primitiveManager = new PrimitiveManager(this.scene); + // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц + // (createEmitterParticles живёт на обёртке). + this.primitiveManager.scene3d = this; + // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture + // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard + // (type='billboard') сразу применить текстуру с дефолтным пресетом. + this.billboardUiManager = new BillboardUiManager(this.scene); + this.primitiveManager.billboardUiManager = this.billboardUiManager; + this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); + this.guiManager = new GuiManager(); + this.modalManager = new ModalManager(); + this.modalManager.attachScene(this); + this.modalManager.attachGui(this.guiManager); + this.inventory = new InventoryManager(); + this.physics = new PhysicsAABB(this.blockManager); + // Сразу синхронизируем границу пола с текущим размером мира, + // иначе при дефолтных 40 игрок проваливается на больших картах + // ещё до первого setWorldSize(). + this.physics.floorHalf = this._worldHalf; + this.physics.setPrimitiveManager(this.primitiveManager); + this.physics.setModelManager(this.modelManager); + this.physics.setUserModelManager(this.userModelManager); + // Voxel-террейн тоже участвует в физике. У террейна свой размер + // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. + this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); + this.vehicleManager = new VehicleManager(this); // задача 14 + // Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике. + // Физика проверяет коллизии в обоих источниках (legacy terrainManager + + // voxelWorld), что позволяет постепенно мигрировать без поломки. + if (this.physics.setVoxelWorld && this.voxelWorld) { + this.physics.setVoxelWorld(this.voxelWorld); + } + this.dynamics = new DynamicsManager(this); + this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); + this.audioManager = new AudioManager(); + this.assetManager = new AssetManager(); + // PrimitiveManager должен уметь брать dataURL картинки по id ассета, + // чтобы применять пользовательскую текстуру на грани примитива. + this.primitiveManager.assetManager = this.assetManager; + // Библиотека пользовательских звуков (Фаза 5.5) — постоянная. + this.soundLibrary = new SoundLibrary(); + // Библиотека импортированных .glb-моделей (Фаза 5.8) — постоянная. + this.glbLibrary = new GlbLibrary(); + this.selection = new SelectionManager(this.scene, this.blockManager, this.modelManager); + this.selection.setPrimitiveManager(this.primitiveManager); + this.selection.setUserModelManager(this.userModelManager); + this.selection.setScene3D(this); + + // GizmoController — управляет 3 типами гизмо (move/rotate/scale). + // UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены. + // Babylon автоматически активирует pointer-observable utility-сцены + // когда родительская scene control включён (мы убрали detachControl). + this._gizmoLayer = new UtilityLayerRenderer(this.scene); + + this._gizmo = new GizmoController(this._gizmoLayer, this.scene); + this._gizmo.setMode('select'); // по умолчанию — без манипулятора + this._gizmo.setSnap(1.0); // снэп для блоков + + // При окончании drag — синхронизируем + this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd()); + + // Привязка гизмо к выделенному + this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel)); + + // History (Undo/Redo). Сериализатор и восстановитель — методы этой сцены. + this.history = new HistoryManager( + () => { + try { return JSON.stringify(this.serialize()); } + catch (e) { return null; } + }, + async (state) => { + // При undo/redo — снимаем выделение (mesh может быть пересоздан) + this.selection?.clear(); + await this.loadFromState(state); + } + ); + // На любые изменения сцены — markChange (debounced) + this.blockManager.setOnChange(() => { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + }); + this.modelManager.setOnChange(() => { + this.history?.markChange(); + // Сбрасываем spatial-индекс физики — модели могли двигаться/добавляться. + this.physics?.setSpatialDirty?.(); + if (this._onSceneChange) this._onSceneChange(); + }); + this.primitiveManager.setOnChange(() => { + this.history?.markChange(); + this.physics?.setSpatialDirty?.(); + if (this._onSceneChange) this._onSceneChange(); + }); + // Этап 5: подписка на изменения user-моделей. + this.userModelManager.setOnChange(() => { + this.history?.markChange(); + this.physics?.setSpatialDirty?.(); + if (this._onSceneChange) this._onSceneChange(); + }); + this.folderManager.setOnChange(() => { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + }); + this.guiManager.setOnChange(() => { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + if (this._onGuiChange) this._onGuiChange(); + // Если в Play — обновляем зеркало в Worker'ах сразу + if (this._isPlaying && this.gameRuntime) { + this.gameRuntime.scheduleGuiSnapshot(); + } + }); + this.inventory.setOnChange(() => { + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + if (this._onInventoryChange) this._onInventoryChange(); + }); + + // Запоминаем начальное (пустое) состояние как точку для undo. + this.history.initialize(); + + this.engine.runRenderLoop(() => { + // Если рендер на паузе (например, активен таб скрипта или вкладка + // браузера в фоне) — пропускаем тик целиком. Освобождаем CPU/GPU + // для Monaco, который иначе лагает на ввод. + if (this._renderingPaused) return; + if (this.scene && this.scene.activeCamera) { + this._updateCameraMovement(); + this._updateGhostPosition(); + const dt = this.engine.getDeltaTime() / 1000; + // Физика unanchored-объектов в Play-режиме + if (this._isPlaying && this.dynamics?.isEnabled()) { + this.dynamics.tick(dt); + } + // Цикл дня/ночи (только в Play-режиме, чтобы редактор не «убегал») + if (this._isPlaying && this.environment) { + this.environment.tick(dt); + } + // Анимация жидкостей — работает всегда (и в редакторе) + if (this.blockManager) { + this.blockManager.tick(dt); + } + // LOD/culling далёких моделей (раз в 5 кадров — экономим CPU) + this._lodFrameCounter = (this._lodFrameCounter || 0) + 1; + if (this._lodFrameCounter % 5 === 0) { + this._updateModelLOD(); + // Примитивы НЕ култим по дистанции — на компактных сценах + // (Squid Game) это убирает куклу/охранников вдали и пользователь + // видит пустое поле. Лучшее решение — пусть Babylon + // frustum-cull'ит сам, у нас уже freezeWorldMatrix. + } + // Этап 4 voxel-streaming: подгрузка/выгрузка чанков по радиусу + // от игрока (в Play) или камеры (в редакторе). Дёргаем раз в + // 250мс — этого достаточно при ходьбе. + // VoxelWorld streaming (новый рендер) — disabled by default, + // используется TerrainManager streaming ниже (legacy подход). + if (this._voxelStreamingEnabled && this._voxelRenderEnabled && this.voxelRenderer) { + const nowMs = performance.now(); + if (nowMs - this._voxelStreamingLastUpdate > 200) { + this._voxelStreamingLastUpdate = nowMs; + let cx, cz; + if (this._isPlaying && this.player && this.player._pos) { + cx = this.player._pos.x; cz = this.player._pos.z; + } else if (this.camera && this.camera.position) { + cx = this.camera.position.x; cz = this.camera.position.z; + } + if (cx !== undefined) { + this.voxelRenderer.updateStreaming({ x: cx, z: cz }, this._voxelStreamingRadius); + } + } + } + + // === LEGACY TerrainManager streaming (region-meshes) === + // Главный механизм производительности для больших карт: + // enable/disable region-meshes legacy террейна по радиусу + // от игрока/камеры. Регионы за пределами radius — disabled, + // не рендерятся GPU. + if (this._terrainStreamingEnabled && this.terrainManager?.updateStreaming) { + const nowMs2 = performance.now(); + // 200мс — реже чем раньше (было 80мс). Streaming = тяжёлая + // операция (обход всех region-meshes), не нужна каждые 80мс. + if (nowMs2 - (this._terrainStreamingLastUpdate || 0) > 200) { + this._terrainStreamingLastUpdate = nowMs2; + let cx, cz; + let radius = this._terrainStreamingRadius || 60; + if (this._isPlaying && this.player && this.player._pos) { + cx = this.player._pos.x; cz = this.player._pos.z; + } else if (this.camera && this.camera.position) { + cx = this.camera.position.x; cz = this.camera.position.z; + const camY = this.camera.position.y || 0; + // Editor radius = play × 1.3 + height bonus. Capped 60м. + // Раньше было ×1.6 + 30 = до 85м (47 регионов в кадре = 14M trianglов). + // Сейчас 32 × 1.3 + 20 = до 60м (~20-25 регионов = ~5M trianglов). + const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); + radius = Math.min(60, radius * 1.3 + heightBonus); + } + // SKIP если камера не сдвинулась >3м с прошлого пересчёта + const prevX = this._terrainStreamingPrevX; + const prevZ = this._terrainStreamingPrevZ; + if (cx !== undefined && prevX !== undefined) { + const ddx = cx - prevX, ddz = cz - prevZ; + if (ddx * ddx + ddz * ddz < 9) { // < 3м + cx = undefined; // отменяет следующий блок + } + } + if (cx !== undefined) { + this._terrainStreamingPrevX = cx; + this._terrainStreamingPrevZ = cz; + } + if (cx !== undefined) { + this.terrainManager.updateStreaming(cx, cz, radius); + // Этап D: deco streaming с МЕНЬШИМ радиусом. + // Декорации видны только вблизи. Минимум 35м чтобы + // chunk 64м не пропадал — иначе видны «дыры». + if (this.decoManager?.updateStreaming) { + const decoRadius = Math.max(18, radius * 0.35); + this.decoManager.updateStreaming(cx, cz, decoRadius); + } + } + } + } + // Задача 04: modalManager.tick — независимо от runtime'а + if (this._isPlaying && this.modalManager?.tick) { + try { this.modalManager.tick(dt); } catch (e) {} + } + // Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused. + if (this._isPlaying && this.loadingScreen?.tick) { + try { this.loadingScreen.tick(dt); } catch (e) {} + } + // Tick пользовательских скриптов: в Play-режиме или в solo-debug + if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) { + this.gameRuntime.tick(dt); + // Детекция touch-событий — раз в 3 кадра (для script onTouch). + // Это O(N×M) = скрипты × примитивы, на мобиле просаживало FPS + // при повороте. 3 кадра ≈ 50мс при 60fps — хватает для UX. + if (this._isPlaying) { + this._touchDetectFrame = (this._touchDetectFrame || 0) + 1; + if (this._touchDetectFrame >= 3) { + this._touchDetectFrame = 0; + this._detectTouchEvents(); + } + } + } + // Анимация полоски перезарядки оружия + if (this._isPlaying && this.weapons) this.weapons.tick(); + // PERF-METRICS: замеряем render() — обычно самая толстая часть. + const _rt0 = performance.now(); + // Замер idle-времени = промежуток между концом предыдущего + // render и началом текущего. Большое idle = GPU-bound. + if (this._perfMetrics && this._perfMetrics._lastRenderEnd > 0) { + const idle = _rt0 - this._perfMetrics._lastRenderEnd; + if (idle > 0 && idle < 1000) { + this._perfMetrics.idle_ms_sum += idle; + this._perfMetrics.idle_count++; + } + } + this.scene.render(); + if (this._perfMetrics) { + const _rt1 = performance.now(); + this._perfMetrics.render_ms_sum += _rt1 - _rt0; + this._perfMetrics.render_count++; + this._perfMetrics._lastRenderEnd = _rt1; + } + } + }); + + // resize при изменении окна + this._resizeHandler = () => this.engine.resize(); + window.addEventListener('resize', this._resizeHandler); + + // Главное: ResizeObserver на canvas. React сначала рендерит canvas + // размером 0, потом раскладка применяет 100%/100%. Engine, созданный + // в момент init, считал размер 0 и backbuffer был пуст. Через RO + // ловим финальный размер и вызываем resize. + if (typeof ResizeObserver !== 'undefined') { + this._ro = new ResizeObserver(() => { + if (this.engine) this.engine.resize(); + }); + this._ro.observe(this.canvas); + } + + // Принудительный resize чуть позже — на случай если RO не сработал + setTimeout(() => { if (this.engine) this.engine.resize(); }, 100); + } + + /** + * UniversalCamera — позволяет ручное управление позицией и yaw/pitch. + * Стартовая позиция: смотрим на (0,0,0) сверху-сбоку. + */ + _createCamera() { + const camera = new UniversalCamera( + 'editorCamera', + new Vector3(15, 15, -20), + this.scene + ); + camera.setTarget(new Vector3(0, 0, 0)); + camera.minZ = 0.1; + camera.maxZ = 1000; + camera.fov = 0.9; + + // ОТКЛЮЧАЕМ стандартное управление — будем писать своё. + camera.inputs.clear(); + + this.camera = camera; + } + + _createLights() { + const hemi = new HemisphericLight( + 'hemiLight', + new Vector3(0, 1, 0), + this.scene + ); + hemi.intensity = 0.65; + hemi.groundColor = new Color3(0.3, 0.3, 0.4); + + const sun = new DirectionalLight( + 'sunLight', + new Vector3(-0.5, -1, -0.3), + this.scene + ); + sun.intensity = 0.8; + sun.position = new Vector3(20, 40, 20); + + // Сохраняем ссылки чтобы Environment мог менять их свойства + this._hemiLight = hemi; + this._sunLight = sun; + + // Тени — по умолчанию мягкие. Создаётся ShadowGenerator при первом + // вызове setShadowQuality, либо сразу через _ensureShadowGenerator. + // MOBILE-OPT (этап 1.5): на мобильном тени = 'hard' (жёсткие — без + // soft-blur, дешевле). 'off' давало плоскую картинку, теперь + // компромисс — есть тени но дешевле soft. + this._shadowQuality = this._isMobileMode ? 'hard' : 'soft'; + this._shadowGenerator = null; + this._ensureShadowGenerator(); + + // SSAO2 — Screen-Space Ambient Occlusion (контактные тени в углах, + // под объектами и в стыках). По умолчанию выключен — это дорогой + // пост-эффект (-15..30% FPS). Включается через setSsaoEnabled(true) / + // setLightingProps({ ssaoEnabled: true }) из инспектора Lighting. + this._ssaoPipeline = null; + this._ssaoEnabled = false; + } + + /** + * Включить/выключить SSAO пост-эффект (контактные тени). + * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер + * (блоки/террейн пропадали) из-за GeometryBufferRenderer. v1 использует + * только depthRenderer и совместим со всеми мешами. + */ + setSsaoEnabled(on) { + const want = !!on; + if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return; + if (!want) { + this._disposeSsaoPipeline(); + this._ssaoEnabled = false; + return; + } + if (!this.scene.activeCamera) { + console.warn('[BabylonScene] SSAO: нет активной камеры'); + return; + } + try { + const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 }; + const pipeline = new SSAORenderingPipeline( + 'ssaopipeline', this.scene, ratio, [this.scene.activeCamera] + ); + pipeline.fallOff = 0.000001; + pipeline.area = 0.0075; + pipeline.radius = 0.0001; + pipeline.totalStrength = 1.0; + pipeline.base = 0.5; + this._ssaoPipeline = pipeline; + this._ssaoEnabled = true; + } catch (e) { + console.warn('[BabylonScene] SSAO не запустился:', e?.message || e); + this._disposeSsaoPipeline(); + this._ssaoEnabled = false; + } + } + + /** Полностью убрать SSAO пайплайн (detach + remove + dispose). */ + _disposeSsaoPipeline() { + if (!this._ssaoPipeline) return; + const mgr = this.scene.postProcessRenderPipelineManager; + const name = this._ssaoPipeline.name || 'ssaopipeline'; + try { + if (mgr && this.scene.activeCamera) { + mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera); + } + } catch (e) { /* ignore */ } + try { + if (mgr && typeof mgr.removePipeline === 'function') { + mgr.removePipeline(name); + } + } catch (e) { /* ignore */ } + try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ } + this._ssaoPipeline = null; + } + + getSsaoEnabled() { return this._ssaoEnabled; } + + /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество. + * + * Поддерживаемые уровни (Этап 2 теней, 2026-05-27): + * - 'off' — теней нет + * - 'hard' — резкие тени, 512px, без блюра + * - 'soft' — мягкие тени, 1024px (на mobile 512), blurKernel 24 + * - 'medium' — CSM 1024 × 3 каскада, для среднего ПК + * - 'high' — CSM 2048 × 4 каскада, дорогой, для топовых ПК + */ + _ensureShadowGenerator() { + const q = this._shadowQuality; + if (q === 'off') { + if (this._shadowGenerator) { + try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } + this._shadowGenerator = null; + } + return null; + } + // Если уже создан — но качество поменялось на тот тип где нужен другой + // движок (CSM vs обычный) — пересоздадим. + const wantCsm = (q === 'medium' || q === 'high'); + const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator; + if (this._shadowGenerator && wantCsm !== haveCsm) { + try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } + this._shadowGenerator = null; + } + + // PCF = Percentage Closer Filtering. Правильная техника мягких теней. + // + // bias 0.0005, normalBias 0.005. Раньше normalBias=0.012 давал + // peter-panning — тень "уезжала" далеко в сторону от блока (баг + // 2026-05-27). 0.005 — баланс между acne и peter-panning для + // воксельных кубов 1м. + const PCF_BIAS = 0.0005; + const PCF_NORMAL_BIAS = 0.005; + + if (!this._shadowGenerator) { + if (wantCsm) { + // CSM с PCF. Поднял разрешение каскадов (2048/4096 — было 1024/2048). + const size = (q === 'high') ? 4096 : 2048; + const numCascades = (q === 'high') ? 4 : 3; + const csm = new CascadedShadowGenerator(size, this._sunLight); + csm.numCascades = numCascades; + csm.stabilizeCascades = true; + csm.lambda = 0.8; + csm.cascadeBlendPercentage = 0.07; + csm.shadowMaxZ = (q === 'high') ? 200 : 120; + csm.bias = PCF_BIAS; + csm.normalBias = PCF_NORMAL_BIAS; + csm.usePercentageCloserFiltering = true; + csm.filteringQuality = (q === 'high') + ? ShadowGenerator.QUALITY_HIGH + : ShadowGenerator.QUALITY_MEDIUM; + csm.darkness = 0.4; + csm.autoCalcDepthBounds = true; + this._shadowGenerator = csm; + } else { + // Обычный ShadowGenerator. Soft теперь 2048 (было 1024). + let shadowSize; + if (q === 'hard') { + shadowSize = this._isMobileMode ? 512 : 1024; + } else { // soft + shadowSize = this._isMobileMode ? 1024 : 2048; + } + const gen = new ShadowGenerator(shadowSize, this._sunLight); + gen.bias = PCF_BIAS; + gen.normalBias = PCF_NORMAL_BIAS; + if (gen.getShadowMap) { + const rtt = gen.getShadowMap(); + if (rtt) rtt.refreshRate = 2; + } + this._shadowGenerator = gen; + } + } + + const gen = this._shadowGenerator; + if (q === 'medium' || q === 'high') { + // параметры CSM выставлены при создании + } else if (q === 'soft') { + gen.usePercentageCloserFiltering = true; + gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM; + gen.useBlurExponentialShadowMap = false; + gen.useKernelBlur = false; + gen.usePoissonSampling = false; + gen.darkness = 0.4; + } else { // hard + gen.usePercentageCloserFiltering = false; + gen.useBlurExponentialShadowMap = false; + gen.useKernelBlur = false; + gen.usePoissonSampling = false; + gen.darkness = 0.55; + } + return gen; + } + + /** + * Изменить качество теней. 'off' уничтожает генератор; 'hard'/'soft'/ + * 'medium'/'high' создают/обновляют. CSM используется для medium/high. + */ + setShadowQuality(q) { + const allowed = ['off', 'hard', 'soft', 'medium', 'high']; + if (!allowed.includes(q)) return; + this._shadowQuality = q; + this._ensureShadowGenerator(); + // Если выключили — снимем receiveShadows с пола (необязательно, но чище) + const ground = this.scene.getMeshByName('editorGround'); + if (ground) ground.receiveShadows = q !== 'off'; + // После смены качества — заново зарегистрировать всех casters + // (при пересоздании генератора список обнулился). + if (q !== 'off') { + try { this.refreshAllShadows(); } catch (e) { /* ignore */ } + } + } + + getShadowQuality() { return this._shadowQuality || 'soft'; } + + /** + * Установить свойства глобального освещения. Вызывается из Inspector + * (selection.type === 'lighting'). + * patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?, + * fogDensity?, fogColor?, shadowQuality? } + */ + setLightingProps(patch) { + if (!patch) return; + // Время суток — пресет / минуты день/ночь + if (patch.envPreset && this.environment) { + try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ } + } + if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) { + this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin); + } + if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) { + this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin); + } + if (typeof patch.sunIntensity === 'number' && this._sunLight) { + this._sunLight.intensity = Math.max(0, patch.sunIntensity); + } + if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { + this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); + } + if (this.environment && typeof this.environment.setFog === 'function') { + // Текущие значения берём из Environment, поверх накладываем patch + const enabled = (typeof patch.fogEnabled === 'boolean') + ? patch.fogEnabled : this.environment.fogEnabled; + let color = this.environment.fogColor; + if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) { + color = [ + parseInt(patch.fogColor.substr(1, 2), 16) / 255, + parseInt(patch.fogColor.substr(3, 2), 16) / 255, + parseInt(patch.fogColor.substr(5, 2), 16) / 255, + ]; + } + const density = (typeof patch.fogDensity === 'number') + ? patch.fogDensity : this.environment.fogDensity; + if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) { + this.environment.setFog(enabled, color, density); + } + } + if (patch.shadowQuality) { + this.setShadowQuality(patch.shadowQuality); + this.refreshAllShadows(); + } + if (typeof patch.ssaoEnabled === 'boolean') { + this.setSsaoEnabled(patch.ssaoEnabled); + } + // Обновить selection чтобы Inspector сразу показывал новые значения + if (this.selection?._selection?.type === 'lighting') { + this.selection.selectLighting(); + } + } + + /** + * Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G). + * Если выделен один — кладёт его одного. Если ничего — no-op. + */ + groupSelected(name = null) { + if (!this.folderManager || !this.selection) return null; + const multi = this.selection.getMultiSelection(); + const items = []; + if (multi.length > 0) { + for (const it of multi) items.push(it); + } else { + const s = this.selection.getSelection(); + if (!s) return null; + if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); + else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId }); + else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id }); + else return null; + } + if (items.length === 0) return null; + const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`; + const folderId = this.folderManager.createFolder(folderName, null); + for (const it of items) { + this.folderManager.assignToFolder(it.kind, it.ref, folderId); + } + return folderId; + } + + /** + * Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно. + * ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы. + * ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode + * этих методов нет, что приводит к runtime-крашу. + */ + /** Удалено: пытались через ShadowGenerator, не сработало. + * Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */ + _enableGdShadows() { /* no-op */ } + + addShadowCaster(mesh) { + if (!this._shadowGenerator || !mesh) return; + // TransformNode не имеет getBoundingInfo/getVerticesData + if (typeof mesh.getBoundingInfo !== 'function') return; + if (typeof mesh.getTotalVertices !== 'function') return; + if (mesh.getTotalVertices() <= 0) return; + // ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский + // пол НЕ кастят тень — каждый caster дорого стоит в shadow-map + // (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60). + try { + const bb = mesh.getBoundingInfo().boundingBox; + const ext = bb.extendSizeWorld || bb.extendSize; + if (ext) { + const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2; + const maxDim = Math.max(w, h, d); + const minDim = Math.min(w, h, d); + if (maxDim < 1.6 || minDim < 0.35) return; + if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return; + } + } catch (e) { /* ignore */ } + try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } + } + + /** + * Зарегистрировать все текущие блоки/модели/примитивы как shadow casters. + * Полезно вызвать после loadFromState или смены качества теней. + */ + refreshAllShadows() { + if (!this._shadowGenerator) return; + if (this.blockManager) { + // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы + if (this.blockManager._protoMeshes) { + for (const proto of this.blockManager._protoMeshes.values()) { + this.addShadowCaster(proto); + } + } + // Жидкости/legacy mesh + for (const m of this.blockManager.blocks.values()) { + if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m); + } + } + if (this.modelManager) { + for (const inst of this.modelManager.instances.values()) { + const root = inst.rootMesh; + if (!root) continue; + // root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы + if (typeof root.getChildMeshes === 'function') { + for (const cm of root.getChildMeshes()) this.addShadowCaster(cm); + } + } + } + if (this.primitiveManager) { + for (const inst of this.primitiveManager.instances.values()) { + if (inst.mesh) this.addShadowCaster(inst.mesh); + } + } + } + + _createGroundGrid() { + // Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF. + const WORLD_HALF = this._worldHalf; + + const ground = MeshBuilder.CreateGround( + 'editorGround', + { width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 }, + this.scene + ); + + // Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн. + // Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует + // 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат. + const TEX_SIZE = 64; + const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false); + baseTex.wrapU = 1; // wrap + baseTex.wrapV = 1; + baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки + baseTex.vScale = WORLD_HALF * 2 / 4; + baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE); + const ctx = baseTex.getContext(); + // Серая основа + ctx.fillStyle = '#7a8071'; + ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE); + // Тёмные «рейки» по периметру плитки (Roblox-style) + ctx.strokeStyle = '#5d6358'; + ctx.lineWidth = 4; + ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4); + // Тонкая внутренняя сетка 4 на плитке + ctx.strokeStyle = '#6c7268'; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const p = (i * TEX_SIZE) / 4; + ctx.beginPath(); + ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2); + ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p); + ctx.stroke(); + } + baseTex.update(); + + const mat = new StandardMaterial('groundMat', this.scene); + mat.diffuseTexture = baseTex; + mat.specularColor = new Color3(0, 0, 0); + ground.material = mat; + ground.receiveShadows = true; + + // Только две осевые линии (X и Z), цветные — для ориентации в редакторе. + // Сетку делает сама baseplate-текстура. + const axisMatX = new StandardMaterial('axisMatX', this.scene); + axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25); + axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1); + axisMatX.specularColor = new Color3(0, 0, 0); + + const axisMatZ = new StandardMaterial('axisMatZ', this.scene); + axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85); + axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5); + axisMatZ.specularColor = new Color3(0, 0, 0); + + // Ось X (красная) — линия вдоль X на z=0 + const axisX = MeshBuilder.CreateBox('axisX', + { width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene); + axisX.position = new Vector3(0, 0.011, 0); + axisX.material = axisMatX; + axisX.isPickable = false; + this._gridLines = [axisX]; + + // Ось Z (синяя) — линия вдоль Z на x=0 + const axisZ = MeshBuilder.CreateBox('axisZ', + { width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene); + axisZ.position = new Vector3(0, 0.011, 0); + axisZ.material = axisMatZ; + axisZ.isPickable = false; + this._gridLines.push(axisZ); + } + + /** + * Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии. + * @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500). + */ + setWorldSize(worldSize) { + const half = Math.max(10, Math.round(worldSize / 2)); + if (half === this._worldHalf) return; + this._worldHalf = half; + // ВАЖНО: physics.floorHalf по умолчанию 40. Если визуальная плита + // больше — игрок проваливается за пределами центрального 80×80 + // квадрата. Синхронизируем физику с визуалом. + if (this.physics) this.physics.floorHalf = half; + // Удалить старый пол + осевые линии + const oldGround = this.scene.getMeshByName('editorGround'); + if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ } + if (Array.isArray(this._gridLines)) { + for (const line of this._gridLines) { + try { line.dispose(); } catch (e) { /* ignore */ } + } + } + this._gridLines = []; + this._createGroundGrid(); + } + + /** Текущий размер пола в юнитах (worldSize, не worldHalf). */ + getWorldSize() { return this._worldHalf * 2; } + + /** Включить/выключить пол (визуально и физически). */ + setFloorEnabled(enabled) { + this._floorEnabled = !!enabled; + if (!this.scene) return; + const ground = this.scene.getMeshByName('editorGround'); + if (ground) ground.setEnabled(this._floorEnabled); + // Линии осей тоже + if (Array.isArray(this._gridLines)) { + for (const line of this._gridLines) { + if (line && line.setEnabled) line.setEnabled(this._floorEnabled); + } + } + // Физика: отключаем коллизию с baseplate, чтобы игрок проваливался + if (this.physics) this.physics.floorEnabled = this._floorEnabled; + } + isFloorEnabled() { return this._floorEnabled !== false; } + + /** + * Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks, + * отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию. + * Вызывается из UI (кнопка «✖» в Генератор-панели). + */ + clearRobloxTerrain() { + let hadTerrain = false; + if (this._robloxTerrain) { + try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {} + // ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации + // решит что terrain уже есть и НЕ переподключит его к physics. + this._robloxTerrain = null; + } + // Декорации тоже чистим (thin-instances очищаются, prototype остаётся + // в памяти для следующего применения). + if (this._smoothDecoManager) { + try { this._smoothDecoManager.clear(); } catch (e) {} + } + this._smoothDecoParams = null; + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(null); + } + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(null); + } + try { this.setFloorEnabled(true); } catch (e) {} + this._spawnPoint = { x: 0, y: 5, z: 0 }; + try { this._updateSpawnMarker?.(); } catch (e) {} + // Прячем мини-карту гладкого ландшафта — grid больше нет. + if (window.__robloxMinimapGrid) { + window.__robloxMinimapGrid = null; + this._terrainStreamingEnabled = false; + } + // Помечаем dirty чтобы автосейв записал пустой robloxTerrain + try { this._onSceneChange?.(); } catch (e) {} + console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`); + } + + /** + * Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше. + * Применяется при enterPlayMode и через player.setJumpPower. + */ + setPlayerJumpPower(mul) { + const m = Math.max(0.2, Math.min(5, Number(mul) || 1)); + this._jumpPowerMul = m; + if (this.player) this.player._jumpPowerMul = m; + } + getPlayerJumpPower() { return this._jumpPowerMul ?? 1; } + + /** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */ + setCrosshair(type) { + const allowed = ['none', 'dot', 'cross', 'circle']; + if (!allowed.includes(type)) return; + this._crosshair = type; + } + getCrosshair() { return this._crosshair || 'none'; } + + /** + * LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую + * матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера. + * Запускается в render-loop из tick(). + */ + _updateModelLOD() { + if (!this.modelManager || !this.camera) return; + const cam = this.camera.position; + const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix + const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE; + const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем + // (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере) + const LOD_CULL_SQ = LOD_CULL * LOD_CULL; + for (const data of this.modelManager.instances.values()) { + const root = data.rootMesh; + if (!root) continue; + // Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ + // подвергаем LOD-freeze — за ними двигает свой менеджер. + const gp = data.gameplay; + if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue; + const dx = root.position.x - cam.x; + const dy = root.position.y - cam.y; + const dz = root.position.z - cam.z; + const distSq = dx * dx + dy * dy + dz * dz; + // Cull + const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false; + if (shouldCull && root._kubikonCulled !== true) { + root.setEnabled(false); + root._kubikonCulled = true; + } else if (!shouldCull && root._kubikonCulled === true) { + root.setEnabled(data.visible !== false); + root._kubikonCulled = false; + } + // Freeze + const shouldFreeze = distSq > LOD_FREEZE_SQ; + if (shouldFreeze && root._kubikonFrozen !== true) { + try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ } + root._kubikonFrozen = true; + } else if (!shouldFreeze && root._kubikonFrozen === true) { + try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ } + root._kubikonFrozen = false; + } + } + } + + /** + * LOD-cull для примитивов: далёкие декорации скрываем, ближние видны. + * Только в Play-режиме (в редакторе пользователь должен видеть всю сцену + * чтобы редактировать). На больших проектах (Only Up: 568 примитивов на + * вертикальной башне) это критично — без LOD при повороте камеры Babylon + * frustum-cull'ит сотни мешей и FPS падает в пол. + */ + _updatePrimitiveLOD() { + if (!this._isPlaying) return; + if (!this.primitiveManager || !this.camera) return; + const cam = this.camera.position; + const CULL = 120; + const CULL_SQ = CULL * CULL; + for (const data of this.primitiveManager.instances.values()) { + const m = data.mesh; + if (!m) continue; + // Не трогаем явно скрытые/невидимые скриптом + if (data.visible === false) continue; + const dx = data.x - cam.x; + const dy = data.y - cam.y; + const dz = data.z - cam.z; + const distSq = dx * dx + dy * dy + dz * dz; + const shouldCull = distSq > CULL_SQ; + if (shouldCull && m._kubikonPrimCulled !== true) { + m.setEnabled(false); + m._kubikonPrimCulled = true; + } else if (!shouldCull && m._kubikonPrimCulled === true) { + m.setEnabled(true); + m._kubikonPrimCulled = false; + } + } + } + + /** + * Roblox-style input handlers. + * Мышиные события — на canvas (только когда мышь над сценой). + * Клавиатурные — на window (работают при любом фокусе, как в реальных + * 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF) + * чтобы клавиши работали на любой раскладке (русская/английская). + */ + _setupInputControls() { + const canvas = this.canvas; + + // === МЫШЬ === + // mousedown на canvas в capture-фазе → срабатывает первым, + // даже если поверх есть другие listeners. + const onMouseDown = (e) => { + if (this._isPlaying) { + // В Play-режиме ЛКМ — клик игрока в forward-направлении. + // При pointer-lock курсор в центре; в third (свободный курсор) + // передаём реальные координаты клика для pick по табличкам. + if (this.placementManager && this.placementManager.isActive()) { + if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; } + if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; } + } + if (e.button === 0) { + const r = canvas.getBoundingClientRect(); + this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); + } + return; + } + // Обновляем pointer координаты для raycast и Gizmo + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + + // Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо — + // отдаём событие Babylon GizmoManager и выходим (не ставим блок). + // Проверка attachedMesh || attachedNode — у разных версий Babylon + // и при attachToMesh vs attachToNode заполняется разное поле. + const hasAttachment = this._gizmo && + (this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode); + if (e.button === 0 && hasAttachment) { + const ulScene = this._gizmoLayer?.utilityLayerScene; + if (ulScene) { + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + if (ulPick && ulPick.hit) { + // Симулируем pointer-events для GizmoManager на utility-scene + ulScene.simulatePointerDown(ulPick); + this._gizmoDragging = true; + e.preventDefault(); + return; + } + } + } + + // Запоминаем стартовую точку любого нажатия — для drag-detection. + this._mouseDownButton = e.button; + this._mouseDownX = e.clientX; + this._mouseDownY = e.clientY; + this._mouseDownTime = Date.now(); + + // ЛКМ + tool=block/erase → активируем drag-постановку. + // Сразу же ставим первый блок в клетке под курсором. + if (e.button === 0 && !e.shiftKey + && (this._activeTool === 'block' || this._activeTool === 'erase')) { + this._isDragPlacing = true; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._dragPlaceTick(false, /*isFirst*/ true); + e.preventDefault(); + } + // ЛКМ + tool=terrain → активируем drag-кисть террейна. + // Shift модификатор обрабатывается внутри _terrainBrushTick (стирание). + else if (e.button === 0 && this._activeTool === 'terrain') { + this._isTerrainBrushing = true; + this._terrainDragLockY = null; + this._terrainHistoryOpen(); // снапшот для undo + this._setTerrainBrushPreviewActive(true); + this._terrainBrushTick(e.shiftKey, /*isFirst*/ true); + e.preventDefault(); + } + // Shift+ЛКМ — drag-удаление (даже если tool=block) + else if (e.button === 0 && e.shiftKey) { + this._isDragPlacing = true; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._dragPlaceTick(true, /*isFirst*/ true); + e.preventDefault(); + } + + if (e.button === 2) { + this._isRotating = true; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + canvas.style.cursor = 'grabbing'; + e.preventDefault(); + e.stopPropagation(); + } else if (e.button === 1) { + this._isPanning = true; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + canvas.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + } + // ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup + // только если был "клик" (не drag). + }; + + // mouseup и mousemove — на window, чтобы drag работал даже когда + // курсор вышел за пределы canvas (стандартное поведение для drag). + const onMouseMove = (e) => { + // Babylon без detachControl сам не пишет в scene.pointerX/Y — + // делаем это руками. Нужны для raycast (scene.pick) и для гизмо. + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + + // Если идёт drag гизмо — проксируем move в utility-scene + if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { + const ulScene = this._gizmoLayer.utilityLayerScene; + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + ulScene.simulatePointerMove(ulPick); + return; + } + + // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке + if (this._isDragPlacing) { + this._dragPlaceTick(e.shiftKey); + return; + } + + // Если идёт drag-кисть террейна — продолжаем рисовать. + // Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера + // зависает в точке первого клика, пока юзер водит мышью. + if (this._isTerrainBrushing) { + this._terrainBrushTick(e.shiftKey, /*isFirst*/ false); + this._updateTerrainBrushPosition(); + return; + } + + // Когда tool=terrain без drag'а — подвигаем preview-меш под курсор + if (this._activeTool === 'terrain') { + this._updateTerrainBrushPosition(); + } + + if (!this._isRotating && !this._isPanning) return; + const dx = e.clientX - this._lastMouseX; + const dy = e.clientY - this._lastMouseY; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + + if (this._isRotating) { + this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY; + this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY; + const limit = Math.PI / 2 - 0.05; + if (this.camera.rotation.x > limit) this.camera.rotation.x = limit; + if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit; + } else if (this._isPanning) { + const right = this._getCameraRight(); + const up = this._getCameraUp(); + this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY)); + this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY)); + } + }; + + const onMouseUp = (e) => { + // Если идёт drag гизмо — отдаём pointerup и завершаем + if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { + const ulScene = this._gizmoLayer.utilityLayerScene; + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + ulScene.simulatePointerUp(ulPick); + this._gizmoDragging = false; + this._mouseDownButton = -1; + return; + } + // Если был drag-кисть террейна — сбрасываем флаг + if (this._isTerrainBrushing) { + this._isTerrainBrushing = false; + this._terrainDragLockY = null; + this._smoothBrushLockY = null; + this._smoothBrushLastPos = null; + this._terrainHistoryClose(); // фиксируем undo-снапшот + this._setTerrainBrushPreviewActive(false); + this._mouseDownButton = -1; + return; + } + // Если был drag-place — просто сбрасываем флаг, клик не обрабатываем + // (первая постановка уже сделана при mousedown). + if (this._isDragPlacing) { + this._isDragPlacing = false; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._mouseDownButton = -1; + return; + } + // Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно) + // → это клик; обрабатываем как редактор-клик (поставить/удалить блок). + if (e.button === 0 && this._mouseDownButton === 0) { + const dx = Math.abs(e.clientX - this._mouseDownX); + const dy = Math.abs(e.clientY - this._mouseDownY); + const dt = Date.now() - this._mouseDownTime; + if (dx < 4 && dy < 4 && dt < 400) { + this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey); + } + } + this._mouseDownButton = -1; + + if (e.button === 2) { + this._isRotating = false; + canvas.style.cursor = 'default'; + } else if (e.button === 1) { + this._isPanning = false; + canvas.style.cursor = 'default'; + } + }; + + const onWheel = (e) => { + e.preventDefault(); + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + this.placementManager.rotate(); + return; + } + const forward = this._getCameraForward(); + const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; + this.camera.position.addInPlace(forward.scale(delta)); + }; + + const onContextMenu = (e) => { + e.preventDefault(); + }; + + // === КЛАВИАТУРА === + // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки. + // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...), + // поэтому надёжно использовать только e.code. + + /** + * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable. + * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод. + * Также игнорируем когда открыта модалка (z-index overlay). + */ + const isTypingTarget = (target) => { + if (!target) return false; + const tag = (target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (target.isContentEditable) return true; + return false; + }; + + const onKeyDown = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.add(e.code); + if (e.shiftKey) this._shiftDown = true; + // Маршрутизация game.onKey в Play-режиме + if (this._isPlaying && this.gameRuntime) { + const key = this._normalizeKey(e); + this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); + } + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; } + if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; } + } + if (e.code === 'KeyF') { + this._focusOnTarget(new Vector3(0, 0, 0)); + } + // Ctrl+D — дублировать выделенное + if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.duplicateSelected(); + return; + } + // Ctrl+C — копировать выделенное в буфер (Фаза 5.10). + if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.copySelected(); + return; + } + // Ctrl+V — вставить из буфера (работает и между проектами). + if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.pasteFromClipboard(); + return; + } + // Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo + if ((e.ctrlKey || e.metaKey) && !this._isPlaying) { + if (e.code === 'KeyZ' && !e.shiftKey) { + e.preventDefault(); + this.undo?.(); + return; + } + if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') { + e.preventDefault(); + this.redo?.(); + return; + } + // Ctrl+G — сгруппировать выделенное в новую папку + if (e.code === 'KeyG') { + e.preventDefault(); + this.groupSelected(); + return; + } + // Ctrl+A — выделить всё + if (e.code === 'KeyA') { + e.preventDefault(); + this.selection?.selectAll(); + return; + } + } + + // R — повернуть ghost-модель на 90° (или выделенную модель) + if (e.code === 'KeyR' && !this._isPlaying) { + const sel = this.selection?.getSelection(); + if (sel?.type === 'model') { + const newAngle = (sel.rotationY || 0) + Math.PI / 2; + this.selection.rotateSelectedModel(newAngle); + } else if (this._activeTool === 'model') { + this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2); + } + } + // Delete / Backspace — удалить выделенный + if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) { + // Приоритет: выбранная инструментом «Выбрать деко» декорация. + if (this._decoSelection) { + this._deleteSelectedDeco(); + e.preventDefault(); + } else if (this.selection?.getSelection()) { + this.selection.deleteSelected(); + e.preventDefault(); + } + } + // Escape — снять выделение + переключиться на инструмент «Выделить» + // (в режиме игры Esc обрабатывает PlayerController — выход из Play). + if (e.code === 'Escape' && !this._isPlaying) { + this.selection?.clear(); + if (this._onEditorEscape) { + try { this._onEditorEscape(); } catch (err) { /* ignore */ } + } + } + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { + e.preventDefault(); + } + }; + + const onKeyUp = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.delete(e.code); + if (!e.shiftKey) this._shiftDown = false; + if (this._isPlaying && this.gameRuntime) { + const key = this._normalizeKey(e); + this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code }); + } + }; + + const onBlur = () => { + this._codes.clear(); + this._shiftDown = false; + this._isRotating = false; + this._isPanning = false; + canvas.style.cursor = 'default'; + }; + + // Регистрация: + // - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место + // для перехвата мыши над сценой; наш обработчик отрабатывает первым, + // до Babylon-овских стандартных listeners. + // - keydown/keyup — на window (клавиатуру всегда слушаем глобально). + // - wheel/contextmenu — на canvas в capture. + canvas.addEventListener('mousedown', onMouseDown, true); + canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); + canvas.addEventListener('contextmenu', onContextMenu, true); + // mousemove/mouseup на window — для drag за пределами canvas. + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + + this._listeners = [ + { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, + { target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } }, + { target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true }, + { target: window, type: 'mousemove', fn: onMouseMove }, + { target: window, type: 'mouseup', fn: onMouseUp }, + { target: window, type: 'keydown', fn: onKeyDown }, + { target: window, type: 'keyup', fn: onKeyUp }, + { target: window, type: 'blur', fn: onBlur }, + ]; + } + + /** + * Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ + * (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены). + * ПКМ нужна только для поворота камеры. + * Вызывается каждый кадр из render loop. + * Используем e.code — независимо от раскладки клавиатуры. + */ + _updateCameraMovement() { + if (this._isPlaying) return; // в режиме игры редактор-камера не движется + const c = this._codes; + if (c.size === 0) return; + + const dt = this.engine.getDeltaTime() / 1000; + const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1); + + const forward = this._getCameraForward(); + const right = this._getCameraRight(); + const worldUp = new Vector3(0, 1, 0); + const move = Vector3.Zero(); + + if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed)); + if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed)); + if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed)); + if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed)); + if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed)); + if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed)); + + if (move.lengthSquared() > 0) { + this.camera.position.addInPlace(move); + } + } + + /** + * Единичный вектор «вперёд» камеры (с учётом её поворота). + */ + _getCameraForward() { + const yaw = this.camera.rotation.y; + const pitch = this.camera.rotation.x; + return new Vector3( + Math.sin(yaw) * Math.cos(pitch), + -Math.sin(pitch), + Math.cos(yaw) * Math.cos(pitch) + ).normalize(); + } + + /** + * Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости). + */ + _getCameraRight() { + const yaw = this.camera.rotation.y; + return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize(); + } + + /** + * Единичный вектор «вверх» относительно камеры. + */ + _getCameraUp() { + const forward = this._getCameraForward(); + const right = this._getCameraRight(); + return Vector3.Cross(right, forward).normalize(); + } + + /** + * Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению. + * Будет использоваться для F — focus on selected. + */ + _focusOnTarget(target) { + const offset = this._getCameraForward().scale(-15); + this.camera.position = target.add(offset); + this.camera.setTarget(target); + } + + // === ИНСТРУМЕНТЫ И БЛОКИ =========================================== + + /** + * Создать "призрачный" блок — полупрозрачный преview, показывает где + * появится блок при клике. + */ + _createGhostBlock() { + const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene); + const mat = new StandardMaterial('ghostMat', this.scene); + mat.diffuseColor = new Color3(0.4, 0.9, 0.4); + mat.alpha = 0.35; + mat.specularColor = new Color3(0, 0, 0); + mat.disableLighting = true; + ghost.material = mat; + ghost.isPickable = false; // raycast его игнорирует + ghost.setEnabled(false); + this._ghostMesh = ghost; + } + + /** + * Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со + * светящейся вершиной. Виден только в редакторе, скрывается в Play. + */ + _createSpawnMarker() { + // Базовый цилиндр-подставка + const base = MeshBuilder.CreateCylinder( + 'spawnMarkerBase', + { diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 }, + this.scene + ); + const baseMat = new StandardMaterial('spawnBaseMat', this.scene); + baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2); + baseMat.emissiveColor = new Color3(0.3, 0.2, 0); + baseMat.specularColor = new Color3(0, 0, 0); + baseMat.alpha = 0.85; + base.material = baseMat; + + // Внутренний светящийся столб + const beam = MeshBuilder.CreateCylinder( + 'spawnMarkerBeam', + { diameter: 0.4, height: 2.5, tessellation: 16 }, + this.scene + ); + const beamMat = new StandardMaterial('spawnBeamMat', this.scene); + beamMat.diffuseColor = new Color3(1, 0.9, 0.3); + beamMat.emissiveColor = new Color3(1, 0.85, 0.2); + beamMat.specularColor = new Color3(0, 0, 0); + beamMat.alpha = 0.4; + beamMat.disableLighting = true; + beam.material = beamMat; + beam.position.y = 1.3; + + // Группируем base+beam в TransformNode чтобы двигать как одно + const root = new TransformNode('spawnMarker', this.scene); + base.parent = root; + beam.parent = root; + root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z); + + // Делаем маркер pickable, чтобы можно было кликнуть и выделить. + // Метаданные для SelectionManager: { isSpawn: true }. + base.isPickable = true; + beam.isPickable = true; + base.metadata = { isSpawn: true }; + beam.metadata = { isSpawn: true }; + + this._spawnMarker = root; + this._spawnMarkerMeshes = [base, beam]; + } + + /** Обновить позицию визуального маркера спавна. */ + _updateSpawnMarker() { + if (!this._spawnMarker) return; + this._spawnMarker.position.set( + this._spawnPoint.x, + this._spawnPoint.y, + this._spawnPoint.z + ); + } + + /** Скрыть/показать маркер спавна. */ + _setSpawnMarkerVisible(visible) { + if (!this._spawnMarker) return; + this._spawnMarker.setEnabled(visible); + // КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым. + // Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если + // disabled у parent TransformNode. Без isPickable=false луч стрельбы + // попадает в столб маркера в 5м перед игроком. + if (this._spawnMarkerMeshes) { + for (const m of this._spawnMarkerMeshes) { + if (m) m.isPickable = visible; + } + } + } + + /** + * Raycast от курсора в сцену. + * Возвращает { mesh, point, normal } либо null если ни во что не попали. + * Игнорирует ghost-блок и линии сетки. + */ + /** + * Нормализация клавиши из KeyboardEvent в простую строку для game.onKey. + * KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ... + */ + _normalizeKey(e) { + const code = e.code || ''; + // Буквы KeyA..KeyZ → 'a'..'z' + if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase(); + // Цифры Digit0..Digit9 → '0'..'9' + if (/^Digit\d$/.test(code)) return code.charAt(5); + // Спецклавиши + const map = { + Space: 'space', + Enter: 'enter', + NumpadEnter: 'enter', + Escape: 'escape', + Tab: 'tab', + Backspace: 'backspace', + ShiftLeft: 'shift', ShiftRight: 'shift', + ControlLeft: 'ctrl', ControlRight: 'ctrl', + AltLeft: 'alt', AltRight: 'alt', + ArrowUp: 'arrowup', ArrowDown: 'arrowdown', + ArrowLeft: 'arrowleft', ArrowRight: 'arrowright', + }; + if (map[code]) return map[code]; + // Fallback — сам key в lower-case + return String(e.key || code).toLowerCase(); + } + + /** + * Pick по центру экрана (для Play-режима где курсор залочен). + * Используется для game.self.onClick — клик луч-форвард игрока. + */ + _pickFromCenter() { + const w = this.engine?.getRenderWidth?.() || this.canvas.width; + const h = this.engine?.getRenderHeight?.() || this.canvas.height; + const pi = this.scene.pick(w / 2, h / 2, (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + }); + if (!pi || !pi.hit) return null; + let mesh = pi.pickedMesh; + if (mesh?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo(pi); + if (proxy) mesh = proxy; + } + return { mesh, point: pi.pickedPoint, pickInfo: pi }; + } + + /** + * Извлечь target {kind, ref} из mesh (proxy/прим/модель). + * Используется при клике/touch в Play. + */ + _meshToTarget(mesh) { + if (!mesh || !mesh.metadata) return null; + const md = mesh.metadata; + if (md.isBlock) { + return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; + } + if (md.isModel) return { kind: 'model', id: md.instanceId }; + if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; + return null; + } + + /** + * Детекция касания игроком объектов с target-скриптами. + * Для каждого target-скрипта проверяем AABB-overlap с игроком. + * Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока + * игрок не выйдет из объекта и не вернётся, повторно touch не вызывается. + */ + _detectTouchEvents() { + const rt = this.gameRuntime; + if (!rt || !this.player?._pos) return; + const scripts = this._scripts || []; + if (scripts.length === 0) return; + // Кэш «контакта»: scriptId → true если сейчас касается + if (!this._touchState) this._touchState = new Map(); + const seen = new Set(); + const px = this.player._pos.x; + const py = this.player._pos.y; // центр капсулы + const pz = this.player._pos.z; + const phw = this.player.HALF_W ?? 0.3; + const phh = this.player.HALF_H ?? 0.9; + const phd = this.player.HALF_D ?? 0.3; + + // EPS — допуск касания. Когда игрок СТОИТ на объекте сверху, + // низ его капсулы строго совпадает с верхом объекта (зазор 0), + // и строгое сравнение AABB даёт «не пересекаются». Расширяем + // зону на EPS, чтобы «стоит на объекте/вплотную» = касание. + // Без этого onTouch финиша/плитки не срабатывает (игрок встал). + const EPS = 0.25; + + // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) + for (const s of scripts) { + if (!s.target) continue; + const key = 's:' + s.id; + seen.add(key); + const aabb = this._targetAABB(s.target); + if (!aabb) continue; + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeEvent(s.target, 'touch', {}); + rt.routeGlobalEvent('playerTouch', { target: s.target }); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeEvent(s.target, 'untouch', {}); + } + } + + // 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта — + // шлём глобальное playerTouch с target. Это позволяет писать + // логику чек-поинтов в одном глобальном скрипте без скриптов на каждом + // триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами. + // Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned): + // для них тоже шлём playerTouch, чтобы игры «поймай объект» + // могли ловить падающие кубы через game.onPlayerTouch. + const prims = this.primitiveManager?.instances; + if (prims && prims.size > 0) { + for (const data of prims.values()) { + const isTrigger = data?.type === 'trigger'; + const isSpawned = data?._scriptSpawned === true; + if (!isTrigger && !isSpawned) continue; + const id = data.id; + // Если на этот примитив УЖЕ повешен target-скрипт — он + // обработан в блоке выше, чтобы не дублировать события. + const hasScript = scripts.some(s => + s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id + ); + if (hasScript) continue; + const key = 'p:' + id; + seen.add(key); + const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; + const overlap = + px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS && + py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS && + pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + // target — строка-ref 'primitive:': её можно + // передать в game.scene.delete и сравнивать. + rt.routeGlobalEvent('playerTouch', { + target: 'primitive:' + id, + }); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + } + } + } + + // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через + // findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта + // и не триггеры — например цели туториала. Событие адресное (по ref). + const watched = rt._watchedTouchRefs; + if (watched && watched.size > 0) { + for (const ref of watched) { + const target = this._refToTarget(ref); + if (!target) continue; + const aabb = this._targetAABB(target); + if (!aabb) continue; + const key = 'w:' + ref; + seen.add(key); + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeInstEvent(ref, 'instTouch', {}); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeInstEvent(ref, 'instUntouch', {}); + } + } + } + + // Чистим устаревшие записи (удалённые скрипты/триггеры) + for (const id of this._touchState.keys()) { + if (!seen.has(id)) this._touchState.delete(id); + } + } + + /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ + _refToTarget(ref) { + if (typeof ref !== 'string') return null; + const colon = ref.indexOf(':'); + if (colon < 0) return null; + const kind = ref.slice(0, colon); + const rest = ref.slice(colon + 1); + if (kind === 'primitive') { + const id = this.gameRuntime?._resolvePrimitiveId + ? this.gameRuntime._resolvePrimitiveId(rest) + : (Number.isFinite(Number(rest)) ? Number(rest) : rest); + return { kind: 'primitive', id }; + } + if (kind === 'model') { + const n = Number(rest); + return { kind: 'model', id: Number.isFinite(n) ? n : rest }; + } + return null; + } + + /** Получить мировой AABB target-объекта (для touch-детекции). */ + _targetAABB(target) { + if (!target) return null; + try { + if (target.kind === 'block') { + const r = target.ref || target; + return { + minX: r.x - 0.5, maxX: r.x + 0.5, + minY: r.y, maxY: r.y + 1, + minZ: r.z - 0.5, maxZ: r.z + 0.5, + }; + } + if (target.kind === 'model') { + const id = target.id ?? target.ref; + return this.modelManager?.getInstanceAABB?.(id) || null; + } + if (target.kind === 'primitive') { + const id = target.id ?? target.ref; + const data = this.primitiveManager?.instances?.get(id); + if (!data) return null; + const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; + return { + minX: data.x - hx, maxX: data.x + hx, + minY: data.y - hy, maxY: data.y + hy, + minZ: data.z - hz, maxZ: data.z + hz, + }; + } + } catch (e) { /* ignore */ } + return null; + } + + /** + * Обработка клика в Play-режиме. + * Делает forward-pick и роутит click-событие: + * - в self-обработчики скриптов (routeEvent с target) + * - в глобальные обработчики (game.onClick) с event.target + */ + _handlePlayClick(clickX, clickY) { + if (!this._isPlaying) return; + + // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. + // Используем forward-вектор игрока (XZ-плоскость) — куда смотрит, + // туда и выстрел. На сервере дальше идёт raycast по другим игрокам. + if (this._mpSync && this.player?._pos) { + try { + const yaw = this.player._yaw || 0; + // forward в плоскости XZ: yaw=0 — смотрим в +Z + const dirX = Math.sin(yaw); + const dirZ = Math.cos(yaw); + this._mpSync.sendShoot( + this.player._pos.x, + this.player._pos.z, + dirX, dirZ, + ); + } catch (e) { /* room closed / mpSync disposed */ } + } + + 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 target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; + const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; + // 1) Self-onClick — только если target есть + if (target) { + this.gameRuntime.routeEvent(target, 'click', { point }); + } + // 2) Глобальный onClick — всегда (даже если попали в пустоту) + this.gameRuntime.routeGlobalEvent('click', { point, target }); + // 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие. + // Для game.player.onToolUse(fn) из скриптов (Фаза 4.2). + try { + const active = this.inventory?.getActive?.(); + if (active) { + this.gameRuntime.routeGlobalEvent('toolUse', { + tool: { + kind: active.kind, + modelTypeId: active.modelTypeId, + name: active.name, + }, + point, target, + }); + } + } catch (e) { /* ignore */ } + } + + /** + * Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer + * после joinOrCreate. При null — отключаем мультиплеер. + */ + /** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры. + * Должно вызываться ДО enterPlayMode, иначе PlayerController создастся + * с дефолтным mouse/keyboard-управлением. */ + setTouchMode(enabled) { + this._touchMode = !!enabled; + // Если уже в Play и есть player — пробрасываем + if (this.player && typeof this.player.setTouchMode === 'function') { + this.player.setTouchMode(this._touchMode); + } + // На тач-устройствах (= мобила/планшет) включаем low-perf-режим + // автоматически: уменьшаем разрешение рендера, отключаем тени, + // увеличиваем dt физики. Это даёт ×2-3 прирост FPS. + if (this._touchMode) this.applyLowPerfMode(); + } + + /** + * Включить «лёгкий» режим рендера для слабых устройств / мобилок. + * Можно вызвать вручную и на десктопе если FPS проседает. + * + * Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно: + * 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию). + * Это даёт х2-х9 буст FPS без видимой потери качества — глаз + * телефонного экрана не различает разницу между DPR=2 и DPR=1. + * В отличие от scalingLevel=2 (рендер в половину родного), здесь + * текстуры остаются чёткими — рендерим в реальное число css-пикселей. + * 2. Отключаем тени — ShadowGenerator самый дорогой эффект. + * 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move. + * 4. maxZ=200 — урезаем дальность рендера. + * + * НЕ делаем (после теста — слишком ухудшало картинку и плавность): + * - hardwareScalingLevel > 1 (давало пикселизацию текстур) + * - FPS-cap 30 через frame-skip (давало дёрганье движения) + * - ZombieManager тик через кадр (тоже дёрганье) + */ + applyLowPerfMode() { + if (this._lowPerfApplied) return; + this._lowPerfApplied = true; + // НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение + // экрана (включая DPR). На современных телефонах GPU справляется, + // а текстуры и текст ника остаются чёткими. Прирост FPS даём за + // счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера. + // Тени — выключаем + try { + if (this._shadowGenerator) { + this._shadowGenerator.dispose(); + this._shadowGenerator = null; + } + this._shadowQuality = 'off'; + } catch (e) { /* ignore */ } + // Скип pointer-move picking — каждый кадр не делаем raycast от мыши + try { this.scene.skipPointerMovePicking = true; } catch (e) {} + // НЕ включаем blockMaterialDirtyMechanism — он ломает свойства + // материалов трейсеров/дебриса (создаются после старта, шейдер не + // пересчитывается, emissiveColor/alpha/disableLighting не применяются). + try { + if (this.scene) { + this.scene.autoClear = true; + this.scene.autoClearDepthAndStencil = true; + } + } catch (e) {} + // Уменьшаем дальность рендера если камера далеко смотрит + try { + if (this.camera && this.camera.maxZ) { + this.camera.maxZ = Math.min(this.camera.maxZ, 200); + } + } catch (e) {} + // FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет. + this._lowPerfFrameSkip = false; + // eslint-disable-next-line no-console + console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)'); + } + + setMultiplayerSync(sync) { + this._mpSync = sync; + // Сразу шлём текущее активное оружие, чтобы remote-клиенты + // увидели его в руке модели сразу после нашего onAdd. + if (sync) { + try { + const active = this.inventory?.getActive?.(); + const modelId = (active && active.kind === 'weapon') + ? (active.modelTypeId || '') + : ''; + sync.sendWeapon(modelId); + } catch (e) { /* ignore */ } + } + } + + _pickFromMouse() { + // 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п. + // Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ + // в этом проходе (он бы вернул thin instance с потерянным индексом + // в новых версиях Babylon — старая боль с выделением и постановкой). + const pi = this.scene.pick( + this.scene.pointerX, + this.scene.pointerY, + (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + if (mesh.metadata?._isBlockProto) return false; // ⬅ важно! + return true; + } + ); + + // 2) Отдельный пик блоков через свой raycast по AABB-сетке. + // Гораздо надёжнее thin-instance pick'а: даём гарантированный + // proxy + нормаль грани попадания. + const blockHit = this._pickBlockManually(); + + // Выбираем ближайший: либо стандартный pick, либо блок. + if (pi && pi.hit && blockHit) { + // Сравниваем по дистанции от камеры + const cam = this.scene.activeCamera?.position; + if (cam) { + const d1Sq = (pi.pickedPoint.x - cam.x) ** 2 + + (pi.pickedPoint.y - cam.y) ** 2 + + (pi.pickedPoint.z - cam.z) ** 2; + const d2Sq = (blockHit.point.x - cam.x) ** 2 + + (blockHit.point.y - cam.y) ** 2 + + (blockHit.point.z - cam.z) ** 2; + if (d2Sq < d1Sq) { + return blockHit; + } + } + return { + mesh: pi.pickedMesh, + point: pi.pickedPoint, + normal: pi.getNormal(true), + pickInfo: pi, + }; + } + if (blockHit) return blockHit; + if (pi && pi.hit) { + return { + mesh: pi.pickedMesh, + point: pi.pickedPoint, + normal: pi.getNormal(true), + pickInfo: pi, + }; + } + return null; + } + + /** + * Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит + * по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks? + * Возвращает { mesh: proxy, point, normal } или null. + * + * Используется DDA (digital differential analyzer) — самый быстрый алгоритм + * для voxel-raycast. + */ + _pickBlockManually() { + if (!this.blockManager || !this.scene.activeCamera) return null; + // Получаем ray из курсора + const camera = this.scene.activeCamera; + const ray = this.scene.createPickingRay( + this.scene.pointerX, this.scene.pointerY, null, camera + ); + const origin = ray.origin; + const dir = ray.direction; + + // DDA для voxel-сетки. + // Стартуем с клетки в которой находится origin + let x = Math.round(origin.x); + let y = Math.floor(origin.y); + let z = Math.round(origin.z); + + // Шаги по каждой оси + const stepX = dir.x > 0 ? 1 : -1; + const stepY = dir.y > 0 ? 1 : -1; + const stepZ = dir.z > 0 ? 1 : -1; + + // Длина шага вдоль луча для перехода на следующую клетку + const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity; + const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity; + const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity; + + // Расстояние до первой границы клетки + // Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5 + const nextBoundaryX = x + 0.5 * stepX; + const nextBoundaryY = stepY > 0 ? (y + 1) : y; + const nextBoundaryZ = z + 0.5 * stepZ; + + let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity; + let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity; + let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity; + + const MAX_STEPS = 200; // максимум 200 клеток по лучу + const MAX_DIST = 100; // и не дальше 100м + + // Какая ось пересечена последней (для вычисления нормали) + let lastAxis = -1; + + for (let i = 0; i < MAX_STEPS; i++) { + // Проверяем клетку (x, y, z) + if (y >= 0 && y < 200) { + const key = `${x},${y},${z}`; + const proxy = this.blockManager.blocks.get(key); + if (proxy && proxy.metadata?.canCollide !== false) { + // Нашли! Вычисляем точку контакта и нормаль. + let t; + let nx = 0, ny = 0, nz = 0; + if (lastAxis === 0) { + // Зашли через X-грань + t = tMaxX - tDeltaX; + nx = -stepX; + } else if (lastAxis === 1) { + t = tMaxY - tDeltaY; + ny = -stepY; + } else if (lastAxis === 2) { + t = tMaxZ - tDeltaZ; + nz = -stepZ; + } else { + // Стартуем уже внутри клетки — нормаль вверх по умолчанию + t = 0; + ny = 1; + } + if (t > MAX_DIST) return null; + const point = { + x: origin.x + dir.x * t, + y: origin.y + dir.y * t, + z: origin.z + dir.z * t, + }; + return { + mesh: proxy, + point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } }, + normal: { x: nx, y: ny, z: nz }, + pickInfo: null, + }; + } + } + // Шаг по ближайшей оси + if (tMaxX < tMaxY && tMaxX < tMaxZ) { + if (tMaxX > MAX_DIST) return null; + x += stepX; + tMaxX += tDeltaX; + lastAxis = 0; + } else if (tMaxY < tMaxZ) { + if (tMaxY > MAX_DIST) return null; + y += stepY; + tMaxY += tDeltaY; + lastAxis = 1; + } else { + if (tMaxZ > MAX_DIST) return null; + z += stepZ; + tMaxZ += tDeltaZ; + lastAxis = 2; + } + } + return null; + } + + + /** + * Обновить позицию ghost-блока под курсором. + * Вызывается каждый кадр когда tool='block'. + */ + _updateGhostPosition() { + if (!this._ghostMesh) return; + if (this._isPlaying) { + this._ghostMesh.setEnabled(false); + return; + } + if (this._activeTool !== 'block' && this._activeTool !== 'model') { + this._ghostMesh.setEnabled(false); + return; + } + const pick = this._pickFromMouse(); + if (!pick) { + this._ghostMesh.setEnabled(false); + return; + } + const target = this._computePlacementCell(pick); + if (!target) { + this._ghostMesh.setEnabled(false); + return; + } + // Не показываем ghost если в этой клетке уже блок (только для tool=block) + if (this._activeTool === 'block' && + this.blockManager?.hasBlock(target.x, target.y, target.z)) { + this._ghostMesh.setEnabled(false); + return; + } + this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z); + // Для модели — отображаем угол поворота через rotation (визуальная подсказка) + if (this._activeTool === 'model') { + this._ghostMesh.rotation.y = this._ghostRotationY; + } else { + this._ghostMesh.rotation.y = 0; + } + this._ghostMesh.setEnabled(true); + } + + /** + * Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок. + * Координаты — это нижний-передний-левый угол клетки (блок занимает + * (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)). + * + * Попали в блок: новая клетка = соседняя по нормали грани. + * Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)). + * + * Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка + * целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5). + * Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление + * без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно. + */ + _computePlacementCell(pick) { + const p = pick.point; + const n = pick.normal || new Vector3(0, 1, 0); + const mesh = pick.mesh; + + if (mesh?.metadata?.isBlock) { + // Соседняя клетка по нормали грани, в которую попали + const m = mesh.metadata; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const nz = Math.round(n.z); + const cell = { + x: m.gridX + nx, + y: m.gridY + ny, + z: m.gridZ + nz, + }; + if (cell.y < 0) return null; + return cell; + } + + // Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain). + // У этих мешей нет metadata.isBlock, но есть свои метки. Берём + // РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности + // там, куда кликнули. Без этого модель вставала на y=0 (baseplate). + const md = mesh?.metadata; + const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain); + if (isTerrain) { + return { + x: Math.round(p.x), + y: p.y, // реальная высота поверхности под курсором + z: Math.round(p.z), + }; + } + + // Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет + // центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5) + // до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX. + const x = Math.round(p.x); + const z = Math.round(p.z); + // Если попали в верхнюю грань пола → ставим на y=0. + // Если попали под низ пола (камера ниже сцены) → не ставим. + if (n.y < 0.5) return null; + return { x, y: 0, z }; + } + + /** + * Обработать клик мыши (вызывается из mouseup если это был клик, не drag). + * tool: 'block' / 'model' / 'erase' / 'select'. + */ + _handleEditorClick(shiftKey, ctrlKey = false) { + if (this._isPlaying) return; + if (!this.blockManager) return; + const pick = this._pickFromMouse(); + if (!pick) { + if (this._activeTool === 'select' && !ctrlKey) { + this.selection?.clear(); + } + return; + } + + const tool = shiftKey ? 'erase' : this._activeTool; + + if (tool === 'select') { + if (this.selection) { + // Для надёжности: если pick.mesh почему-то остался прото-мешем + // (без metadata.isBlock), пробуем разрезолвить через + // findProxyByPickInfo ещё раз. + let selectMesh = pick.mesh; + if (selectMesh?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo); + if (proxy) selectMesh = proxy; + } + if (ctrlKey) { + this.selection.toggleMeshSelection(selectMesh); + } else { + this.selection.selectByMesh(selectMesh); + } + } + } else if (tool === 'block') { + const target = this._computePlacementCell(pick); + if (!target) return; + // Блоки живут в целочисленной сетке. Если кликнули по террейну, + // _computePlacementCell вернёт нецелый y (реальная высота + // поверхности) — округляем, чтобы блок встал ровно в клетку. + const by = Math.round(target.y); + const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType); + this._lastPlacedKey = `${target.x},${by},${target.z}`; + // Авто-выделение поставленного блока. Тени уже работают через proto-меш + // (зарегистрирован в refreshAllShadows и обновляется автоматически). + if (mesh) { + this.selection?.selectBlockAt(target.x, by, target.z); + if (this._onPostPlace) this._onPostPlace(); + } + } else if (tool === 'model') { + if (!this._activeModelType) return; + const cell = this._computePlacementCell(pick); + if (!cell) return; + // Пользовательская voxel-модель (id 'user:') — отдельный путь. + if (typeof this._activeModelType === 'string' + && this._activeModelType.startsWith(USER_MODEL_PREFIX)) { + this.userModelManager.addInstance( + this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY, + { currentUserId: this._currentUserId || null }, + ).then(instId => { + if (instId != null) { + const data = this.userModelManager.instances.get(instId); + if (data?.meshes) { + for (const m of data.meshes) this.addShadowCaster(m); + } + // Регистрируем коллайдер + this._syncUserModelColliders(); + if (this._onPostPlace) this._onPostPlace(); + try { this._onSceneChange?.(); } catch (e) {} + // Инкремент uses_count — fire-and-forget + const numericId = parseUserModelId(this._activeModelType); + if (numericId != null && this._userModelsApi?.incrementModelUses) { + this._userModelsApi.incrementModelUses(numericId) + .catch(() => {}); + } + } + }); + return; + } + // addInstance модели — async, ждём id и выделяем + Promise.resolve(this.modelManager.addInstance( + this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY + )).then(instId => { + if (instId != null) { + const data = this.modelManager.instances.get(instId); + if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') { + // rootMesh — TransformNode, пропускаем его и берём только меши + for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm); + } + this.selection?.selectModelByInstanceId(instId); + if (this._onPostPlace) this._onPostPlace(); + } + }); + } else if (tool === 'primitive') { + if (!this._activePrimitiveType) return; + const cell = this._computePlacementCell(pick); + if (!cell) return; + // Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке. + // Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight. + const def = getPrimitiveType(this._activePrimitiveType); + const halfH = (def.defaultScale.y) / 2; + const newId = this.primitiveManager.addInstance(this._activePrimitiveType, { + x: cell.x, y: cell.y + halfH, z: cell.z, + }); + // Авто-выделение поставленного примитива + if (newId != null) { + const data = this.primitiveManager.instances.get(newId); + if (data?.mesh) this.addShadowCaster(data.mesh); + this.selection?.selectPrimitiveById(newId); + if (this._onPostPlace) this._onPostPlace(); + } + } else if (tool === 'erase') { + if (pick.mesh?.metadata?.isBlock) { + this.blockManager.removeBlockByMesh(pick.mesh); + } else if (pick.mesh?.metadata?.isModel) { + this.modelManager.removeInstanceByMesh(pick.mesh); + } else if (pick.mesh?.metadata?.isPrimitive) { + this.primitiveManager.removeInstanceByMesh(pick.mesh); + } + } + } + + /** + * Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ + * удерживается и активен tool=block/erase. + * + * Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем + * плоскость первого блока (X/Y/Z в зависимости от грани попадания): + * - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y) + * - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X) + * - и т.д. + * + * isFirst=true — это первый клик drag'а, запоминаем ось фиксации. + */ + _dragPlaceTick(shiftKey, isFirst = false) { + if (this._isPlaying || !this.blockManager) return; + const tool = shiftKey ? 'erase' : this._activeTool; + if (tool !== 'block' && tool !== 'erase') return; + + const pick = this._pickFromMouse(); + if (!pick) return; + + if (tool === 'block') { + const target = this._computePlacementCell(pick); + if (!target) return; + + // Первый клик — запоминаем ось фиксации по нормали попадания + if (isFirst) { + const n = pick.normal; + if (Math.abs(n.y) > 0.5) { + this._dragLockAxis = 'y'; + this._dragLockValue = target.y; + } else if (Math.abs(n.x) > 0.5) { + this._dragLockAxis = 'x'; + this._dragLockValue = target.x; + } else if (Math.abs(n.z) > 0.5) { + this._dragLockAxis = 'z'; + this._dragLockValue = target.z; + } else { + this._dragLockAxis = null; + } + } else if (this._dragLockAxis) { + // На последующих движениях — переопределяем target в плоскости + if (target[this._dragLockAxis] !== this._dragLockValue) { + // Курсор ушёл с зафиксированной плоскости. Пересчитываем + // через raycast на полу/блоке, но с принудительной координатой. + target[this._dragLockAxis] = this._dragLockValue; + } + } + + const key = `${target.x},${target.y},${target.z}`; + if (key === this._lastPlacedKey) return; + if (this.blockManager.hasBlock(target.x, target.y, target.z)) return; + this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType); + this._lastPlacedKey = key; + } else if (tool === 'erase') { + if (pick.mesh?.metadata?.isBlock) { + const m = pick.mesh.metadata; + const key = `${m.gridX},${m.gridY},${m.gridZ}`; + if (key === this._lastPlacedKey) return; + this.blockManager.removeBlockByMesh(pick.mesh); + this._lastPlacedKey = key; + } + } + } + + /** + * Обновить гизмо под текущее выделение. + */ + _updateGizmoForSelection(sel) { + if (!this._gizmo) return; + if (!sel) { + this._gizmo.attachTo(null); + return; + } + if (sel.type === 'block') { + this._gizmo.attachTo(sel.mesh); + } else if (sel.type === 'model' || sel.type === 'spawn' + || sel.type === 'userModel') { + this._gizmo.attachTo(sel.rootMesh); + } else if (sel.type === 'primitive') { + this._gizmo.attachTo(sel.mesh); + } + // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) + // гарантированно пересоздалась поверх нового attached-mesh. + // Без этого гизмо иногда оказывается привязанным к старому или null + // объекту и стрелки становятся «неактивными». + this._gizmo.refreshMode(); + } + + /** + * Гизмо манипулировал объектом — синхронизируем через SelectionManager. + * Тип операции (move/rotate/scale) определяется по режиму гизмо. + */ + _onGizmoDragEnd() { + if (!this.selection || !this._gizmo) return; + const sel = this.selection.getSelection(); + if (!sel) return; + const mode = this._gizmo.getMode(); + + if (sel.type === 'block') { + if (mode === 'move') { + // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) + const newX = Math.round(sel.mesh.position.x); + const newY = Math.round(sel.mesh.position.y - 0.5); + const newZ = Math.round(sel.mesh.position.z); + if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) { + sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); + return; + } + if (this.blockManager.hasBlock(newX, newY, newZ)) { + sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); + return; + } + this.selection.moveSelectedBlock(newX, newY, newZ); + } + // Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены). + // Если пользователь дёрнул rotate/scale — игнорируем. + } else if (sel.type === 'model') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + this.selection.rotateSelectedModel(root.rotation.y); + } else if (mode === 'scale') { + // Берём средний масштаб (для равномерного скейла) + const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; + this.selection.scaleSelectedModel(avg); + } + } else if (sel.type === 'userModel') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + this.selection.rotateSelectedUserModel(root.rotation.y); + } else if (mode === 'scale') { + const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; + this.selection.scaleSelectedUserModel(avg); + } + } else if (sel.type === 'spawn') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z); + } + } else if (sel.type === 'primitive') { + const root = sel.mesh; + if (mode === 'move') { + this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + // Сохраняем поворот в data → попадёт в serialize при save. + this.primitiveManager?.updateInstance(sel.id, { + rotationX: root.rotation.x, + rotationY: root.rotation.y, + rotationZ: root.rotation.z, + }); + } else if (mode === 'scale') { + // Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится). + const newSx = sel.sx * root.scaling.x; + const newSy = sel.sy * root.scaling.y; + const newSz = sel.sz * root.scaling.z; + root.scaling.set(1, 1, 1); + this.selection.resizeSelectedPrimitive(newSx, newSy, newSz); + // resizeSelectedPrimitive уже обновил sel.mesh на новый и + // вызвал _highlightMesh. Перепривязываем гизмо к новому mesh. + const updatedSel = this.selection.getSelection(); + if (updatedSel?.mesh) { + this._gizmo.attachTo(updatedSel.mesh); + } + } + } + } + + /** Публичный сеттер: переключить инструмент извне (из React-компонента). */ + setActiveTool(toolName) { + this._activeTool = toolName; + if (this._ghostMesh) { + this._ghostMesh.setEnabled(toolName === 'block'); + } + // Preview-кисть террейна. Если меш ещё не создан — создадим лениво + // при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect). + if (toolName === 'terrain') { + if (!this._terrainBrushPreview && this._terrainBrush) { + this._updateTerrainBrushPreview(); + } + if (this._terrainBrushPreview) { + this._terrainBrushPreview.setEnabled(true); + } + } else if (this._terrainBrushPreview) { + this._terrainBrushPreview.setEnabled(false); + } + } + + /** + * Обновить состояние кисти ландшафта из TerrainPanel. + * Принимает частичный объект — то что не задано, не меняется. + */ + setTerrainBrush(patch) { + if (!this._terrainBrush) return; + const prevTool = this._terrainBrush.tool; + Object.assign(this._terrainBrush, patch || {}); + this._updateTerrainBrushPreview(); + // Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций, + // при уходе с инструмента — выключаем и снимаем подсветку. + const nowPick = this._terrainBrush.tool === 'pickDeco'; + const wasPick = prevTool === 'pickDeco'; + if (nowPick !== wasPick) { + if (this._smoothDecoManager?.setPickingEnabled) { + this._smoothDecoManager.setPickingEnabled(nowPick); + } + if (!nowPick) this._clearDecoSelection(); + } + } + + /** Снять подсветку выбранной декорации (маркер-сфера). */ + _clearDecoSelection() { + if (this._decoSelMarker) { + try { this._decoSelMarker.dispose(); } catch (e) {} + this._decoSelMarker = null; + } + this._decoSelection = null; + } + + /** + * Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям, + * подсветка выбранного дерева/куста маркером. Удаление — по Del + * (обрабатывается в _deleteSelectedDeco). + */ + _pickDecoTick() { + const dm = this._smoothDecoManager; + if (!dm) return; + const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, + (m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_')); + if (!pick || !pick.hit || !pick.pickedMesh) { + this._clearDecoSelection(); + return; + } + const thinIdx = pick.thinInstanceIndex; + const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx); + if (!found) { + this._clearDecoSelection(); + return; + } + this._decoSelection = found; + // Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко. + if (this._decoSelMarker) { + try { this._decoSelMarker.dispose(); } catch (e) {} + } + const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene); + const mat = new StandardMaterial('__decoSelMarkerMat', this.scene); + mat.emissiveColor = new Color3(1, 0.85, 0.1); + mat.alpha = 0.35; + mat.disableLighting = true; + marker.material = mat; + marker.isPickable = false; + marker.position.set(found.x, found.y + 2, found.z); + this._decoSelMarker = marker; + console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`); + } + + /** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */ + _deleteSelectedDeco() { + if (!this._decoSelection || !this._smoothDecoManager) return false; + const { decoKey, fullIndex } = this._decoSelection; + const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex); + if (ok) { + // Пересинхронизировать tree-collider'ы (вдруг удалили дерево) + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + this._clearDecoSelection(); + try { this._onSceneChange?.(); } catch (e) {} + } + return ok; + } + + /** + * Обновить geometry preview-меша кисти (полупрозрачная сфера/куб + * по форме кисти, размер 2*radius). Цвет берётся из текущего материала. + */ + _updateTerrainBrushPreview() { + if (!this._terrainBrush) return; + const { brushSize, shape, material } = this._terrainBrush; + const r = Math.max(1, brushSize); + // Берём цвет напрямую из палитры — стабильный hex, не зависит от того + // загружена ли уже текстура. Превью кисти просто тонируется в основной + // цвет выбранного материала. + // eslint-disable-next-line global-require + const { Color3 } = require('@babylonjs/core'); + let matCol = null; + try { + const def = TERRAIN_MATERIAL_DEFS?.[material]; + if (def?.color) matCol = Color3.FromHexString(def.color); + } catch (e) {} + + // Удаляем старый preview если форма/размер/режим изменились + if (this._terrainBrushPreview) { + const md = this._terrainBrushPreview.metadata || {}; + const curMode = this._terrainBrush?.terrainMode || 'voxel'; + if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) { + try { this._terrainBrushPreview.dispose(); } catch (e) {} + this._terrainBrushPreview = null; + } + } + + if (!this._terrainBrushPreview) { + // eslint-disable-next-line global-require + const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); + // Размер кисти в МИРОВЫХ единицах: + // voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр) + // smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2 + const isSmooth = this._terrainBrush?.terrainMode === 'smooth'; + const worldDiameter = isSmooth + ? r * 2 + : (r * 2 + 1) * TERRAIN_VOXEL_SIZE; + let mesh; + if (shape === 'cube') { + mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene); + } else if (shape === 'cylinder') { + mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene); + } else { + mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene); + } + const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene); + mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3); + mat.diffuseColor = new Color3(0, 0, 0); + mat.alpha = 0.22; + mat.disableLighting = false; + mat.backFaceCulling = false; + mesh.material = mat; + mesh.isPickable = false; + mesh.metadata = { + _isTerrainBrushPreview: true, + shape, radius: r, + terrainMode: this._terrainBrush?.terrainMode || 'voxel', + _baseAlpha: 0.22, + _activeAlpha: 0.45, + }; + mesh.setEnabled(this._activeTool === 'terrain'); + this._terrainBrushPreview = mesh; + } else { + // Обновляем цвет + if (matCol) { + try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {} + } + } + } + + /** + * Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в + * voxel-координатах (целые) или null. + * + * Логика: + * 1. Если попали по существующему voxel'у — берём клетку или соседнюю + * по нормали (в зависимости от инструмента). + * 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт + * площадку «где начнётся ландшафт». + */ + _pickTerrainCell(forNewVoxel) { + const pi = this.scene.pick( + this.scene.pointerX, + this.scene.pointerY, + (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh === this._terrainBrushPreview) return false; + if (mesh.metadata?._isBlockProto) return false; + if (mesh.metadata?._isTerrainProto) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + } + ); + + // Сначала — попытка raycast по существующему террейну (DDA) + const camera = this.scene.activeCamera; + const ray = this.scene.createPickingRay( + this.scene.pointerX, + this.scene.pointerY, + null, + camera, + false, + ); + const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200); + if (tHit) { + if (forNewVoxel) { + return { + x: tHit.cell.x + tHit.normal.x, + y: tHit.cell.y + tHit.normal.y, + z: tHit.cell.z + tHit.normal.z, + }; + } + return tHit.cell; + } + + // Иначе — попадание по обычным мешам (пол/блок). Делим мировые + // координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс. + if (pi?.hit && pi.pickedPoint) { + const p = pi.pickedPoint; + const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 }; + // Идём на 0.001 внутрь по противоположной нормали — чтобы координата + // упала в правильный voxel. + const inside = { + x: p.x - n.x * 0.001, + y: p.y - n.y * 0.001, + z: p.z - n.z * 0.001, + }; + const S = TERRAIN_VOXEL_SIZE; + const baseX = Math.floor(inside.x / S); + const baseY = Math.floor(inside.y / S); + const baseZ = Math.floor(inside.z / S); + if (forNewVoxel) { + return { + x: baseX + Math.round(n.x), + y: baseY + Math.round(n.y), + z: baseZ + Math.round(n.z), + }; + } + return { x: baseX, y: baseY, z: baseZ }; + } + + // Fallback — raycast на плоскость y=0. Преобразуем мировые + // координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE. + if (Math.abs(ray.direction.y) > 1e-4) { + const t = -ray.origin.y / ray.direction.y; + if (t > 0 && t < 200) { + const hx = ray.origin.x + ray.direction.x * t; + const hz = ray.origin.z + ray.direction.z * t; + const S = TERRAIN_VOXEL_SIZE; + const cx = Math.floor(hx / S); + const cz = Math.floor(hz / S); + // Прилипание к верху столбца: если в этом столбце уже + // есть voxel'ы террейна, используем Y верхнего + 1. + // Это убирает «прыжок» кисти когда юзер начинает рисовать + // на пол рядом с уже существующим холмом — она остаётся + // на уровне его поверхности. + let topY = -1; + if (this.terrainManager) { + const found = this.terrainManager._findTopY?.(cx, cz, 200, -50); + if (found !== null && found !== undefined) topY = found; + } + return { + x: cx, + y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0), + z: cz, + }; + } + } + return null; + } + + /** + * Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой + * ЛКМ когда activeTool === 'terrain'. + * + * shiftKey — модификатор «обратное действие» (стереть/опустить). + */ + _terrainBrushTick(shiftKey, isFirst) { + if (this._isPlaying) return; + if (this._activeTool !== 'terrain') return; + + // === Smooth-режим: редактируем DensityGrid через SmoothBrushes === + if (this._terrainBrush?.terrainMode === 'smooth') { + this._smoothBrushTick(shiftKey, isFirst); + return; + } + + if (!this.terrainManager) return; + const tool = this._terrainBrush?.tool || 'draw'; + + // === Rate-limit voxel-кисти === + // mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16 + // = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр. + // Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно + // покрывает поверхность за счёт drag по экрану. + if (!isFirst) { + const now = performance.now(); + const last = this._voxelBrushLastTick || 0; + const radius = this._terrainBrush?.brushSize || 4; + // Чем больше кисть, тем реже тики (защита от лагов). + const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120; + if (now - last < minInterval) return; + this._voxelBrushLastTick = now; + } else { + this._voxelBrushLastTick = performance.now(); + } + + // === Plant-кисти voxel-режима: размещение мини-воксельных моделей === + // plantGrass / plantFlower / plantMushroom / plantTree. + // Shift = стереть декорации в зоне. + if (tool === 'plantGrass' || tool === 'plantFlower' + || tool === 'plantMushroom' || tool === 'plantTree') { + // При новом клике сбрасываем rate-limit pos, чтобы первый клик + // в той же точке всегда срабатывал. + if (isFirst) { + this._voxelTreeLastPos = null; + this._voxelPlantLastPos = null; + } + const cell = this._pickTerrainCell(true); + if (!cell) return; + const brush = { + x: cell.x, y: cell.y, z: cell.z, + radius: this._terrainBrush.brushSize || 4, + shape: this._terrainBrush.shape || 'sphere', + strength: this._terrainBrush.strength ?? 50, + }; + if (shiftKey) { + this._eraseDecorationsInBrush(brush); + } else { + this._placeVoxelPlantsAtBrush(brush, tool); + } + return; + } + + // Для перекраски/выровнять — берём клетку с поверхности (не «над») + // Для рисования — клетку «над» (по нормали) + const wantsAdjacent = (tool === 'draw' || tool === 'sculpt'); + const cell = this._pickTerrainCell(wantsAdjacent); + if (!cell) return; + + // На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag + // не перепрыгивать на разные слои каждым движением мыши. + if (isFirst) { + this._terrainDragLockY = cell.y; + } + + const brush = { + x: cell.x, + y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth') + ? this._terrainDragLockY ?? cell.y + : cell.y, + z: cell.z, + radius: this._terrainBrush.brushSize || 4, + shape: this._terrainBrush.shape || 'sphere', + }; + const matId = this._terrainBrush.material || 'grass'; + const strength = this._terrainBrush.strength ?? 50; + + if (shiftKey) { + // Shift = обратная кисть. Для sculpt — опускает (Sculpt Down). + // Для всех остальных — стирает (voxels + декорации в зоне). + if (tool === 'sculpt') { + this.terrainManager.brushSculpt(brush, -1, matId, strength); + } else { + this.terrainManager.brushErase(brush); + this._eraseDecorationsInBrush(brush); + } + return; + } + + // === Деко-материалы → мини-воксельные модели === + // Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть + // ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw, + // sculpt (рисование) — где пользователь "красит" декорациями. + // smooth/paint/flatten — стандартный voxel-rendering. + if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) { + this._placeDecoModelsAtBrush(brush, matId); + return; + } + + // Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten), + // нельзя засыпать столбцы декорациями — fallback на 'grass'. + const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId; + + switch (tool) { + case 'draw': + this.terrainManager.brushDraw(brush, matId); + break; + case 'sculpt': + this.terrainManager.brushSculpt(brush, +1, matId, strength); + break; + case 'smooth': + // Smooth работает БЕЗ выбранного материала: засыпка идёт + // тем материалом, что уже есть у соседних solid voxels. + this.terrainManager.brushSmooth(brush, null); + break; + case 'paint': + this.terrainManager.brushPaint(brush, safeMatId); + break; + case 'flatten': + this.terrainManager.brushFlatten(brush, safeMatId); + break; + case 'erase': + // Стираем И voxels И декорации в зоне кисти. + this.terrainManager.brushErase(brush); + this._eraseDecorationsInBrush(brush); + break; + default: + break; + } + } + + /** Переключение «активного» состояния preview-меша кисти террейна. + * active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */ + _setTerrainBrushPreviewActive(active) { + const m = this._terrainBrushPreview; + if (!m || !m.material) return; + try { + const meta = m.metadata || {}; + m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22); + } catch (e) {} + } + + // ======================================================================== + // Undo / Redo для террейна + // + // Хранится стек снапшотов всего террейна (массив serialize'данных). + // Один drag-мазок кистью = один снапшот. История ограничена 30 шагами + // (чтобы не съесть RAM при больших террейнах). + // + // Использование: + // _terrainHistoryOpen() — перед началом мазка (mousedown по террейну) + // _terrainHistoryClose() — после конца мазка (mouseup): если что-то + // изменилось, фиксируем «открытый» снапшот + // undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y + // ======================================================================== + + /** + * Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на + * `modelId` из DECO_MODELS. Если material есть в этой карте — кисть + * ставит МОДЕЛЬ через DecoManager, а не плоский voxel. + */ + _decoMaterialToModels(matId) { + switch (matId) { + case 'tall_grass': + // Случайная модель травы из pool — каждый клик ставит разные + return GRASS_MODELS_POOL; + case 'flower_red': return ['poppy']; + case 'flower_blue': return ['cornflower']; + case 'flower_yellow': return ['dandelion', 'daisy']; + case 'mushroom_red': return ['fly_mushroom']; + // Эти материалы можно тоже спрятать под деко если хочется, + // но пока оставляем как voxels (они нужны для деревьев и т.п.) + // case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk': + default: return null; + } + } + + /** True если material — декорация (ставится моделью). */ + _isDecoMaterial(matId) { + return this._decoMaterialToModels(matId) !== null; + } + + /** + * Поставить мини-воксельные модели в зоне кисти (sphere/cube). + * Плотность — 30% точек grid в радиусе → штук 5-15 на клик. + */ + _placeDecoModelsAtBrush(brush, matId) { + if (!this.decoManager) return; + const models = this._decoMaterialToModels(matId); + if (!models || models.length === 0) return; + const TERRAIN_VOXEL = 0.25; + const r = brush.radius; + // brush.x/y/z в voxel-индексах террейна (cells 0.25м). + // Мировые координаты центра brush в МЕТРАХ. + const cx = (brush.x + 0.5) * TERRAIN_VOXEL; + const cz = (brush.z + 0.5) * TERRAIN_VOXEL; + // Top-surface Y: ищем верх solid voxels под brush.x,brush.z. + // Если на этой колонне нет voxel — ставим прямо на baseplate y=0. + let topVoxY = brush.y; + // count = ~ 8 случайных позиций + const COUNT = 10; + const placedKeys = new Set(); + for (let i = 0; i < COUNT; i++) { + // Случайная точка в круге radius (в voxel-units) + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r; + const vx = brush.x + Math.cos(angle) * rr; + const vz = brush.z + Math.sin(angle) * rr; + const worldX = (vx + 0.5) * TERRAIN_VOXEL; + const worldZ = (vz + 0.5) * TERRAIN_VOXEL; + // Y: top surface — используем brush.y + 1 voxel (над поверхностью) + const worldY = (topVoxY + 1) * TERRAIN_VOXEL; + // Защита от дублирования: не ставим 2 модели в одну сетку 0.5м + const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; + if (placedKeys.has(key)) continue; + placedKeys.add(key); + // Случайная модель из набора + const modelId = models[Math.floor(Math.random() * models.length)]; + const rotation = Math.random() * Math.PI * 2; + const scale = 0.9 + Math.random() * 0.3; + this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); + } + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Поставить декорации plant-кистью voxel-режима по типу инструмента. + * tool: plantGrass | plantFlower | plantMushroom | plantTree. + * Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели. + */ + _placeVoxelPlantsAtBrush(brush, tool) { + if (tool === 'plantTree') { + this._placeVoxelTreesAtBrush(brush); + return; + } + // Подбираем пул моделей по типу инструмента + let models; + let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне) + switch (tool) { + case 'plantGrass': + models = GRASS_MODELS_POOL; + countMul = 1.5; + break; + case 'plantFlower': + models = ['daisy', 'cornflower', 'poppy', 'dandelion']; + countMul = 1.0; + break; + case 'plantMushroom': + models = ['fly_mushroom', 'brown_mushroom']; + countMul = 0.5; + break; + default: + return; + } + if (!this.decoManager || !models || models.length === 0) return; + // Rate-limit между тиками: не ставим если кисть не сдвинулась. + const r = brush.radius; + const minDist = Math.max(1, r * 0.3); + const minDist2 = minDist * minDist; + if (this._voxelPlantLastPos) { + const dx = brush.x - this._voxelPlantLastPos.x; + const dz = brush.z - this._voxelPlantLastPos.z; + if (dx * dx + dz * dz < minDist2) return; + } + this._voxelPlantLastPos = { x: brush.x, z: brush.z }; + const TERRAIN_VOXEL = 0.25; + const topVoxY = brush.y; + // Кол-во точек пропорционально радиусу и типу декорации. + const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul))); + const placedKeys = new Set(); + for (let i = 0; i < COUNT; i++) { + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r; + const vx = brush.x + Math.cos(angle) * rr; + const vz = brush.z + Math.sin(angle) * rr; + const worldX = (vx + 0.5) * TERRAIN_VOXEL; + const worldZ = (vz + 0.5) * TERRAIN_VOXEL; + const worldY = (topVoxY + 1) * TERRAIN_VOXEL; + const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; + if (placedKeys.has(key)) continue; + placedKeys.add(key); + const modelId = models[Math.floor(Math.random() * models.length)]; + const rotation = Math.random() * Math.PI * 2; + const scale = 0.9 + Math.random() * 0.3; + this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); + } + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью. + * + * Логика как в smooth-режиме (`_smoothBrushTickPlant`): + * - 1 дерево за тик (а не пучок) + * - rate-limit: если кисть не сдвинулась далеко, пропускаем тик + * - случайный выбор типа: oak / birch / autumn + * + * Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм, + * который генерирует деревья в процедурном мире — толстый ствол, + * корни, ветви-зигзаги, главная крона + кроны на ветвях). + */ + _placeVoxelTreesAtBrush(brush) { + if (!this.terrainManager) return; + const tm = this.terrainManager; + + // === Rate-limit между тиками === + // Один тик = одно дерево. Если кисть не сдвинулась более чем на + // 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев + // друг на друге при удержании ЛКМ. + const r = brush.radius; + const minDist = Math.max(2, r * 0.4); + const minDist2 = minDist * minDist; + if (this._voxelTreeLastPos) { + const dx = brush.x - this._voxelTreeLastPos.x; + const dz = brush.z - this._voxelTreeLastPos.z; + if (dx * dx + dz * dz < minDist2) return; + } + this._voxelTreeLastPos = { x: brush.x, z: brush.z }; + + // === Случайная точка в круге кисти (jitter) === + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру + const tx = Math.round(brush.x + Math.cos(angle) * rr); + const tz = Math.round(brush.z + Math.sin(angle) * rr); + + // === Top-surface для этой XZ === + const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4); + const baseY = (topY === null || topY === undefined) ? brush.y : topY; + + // === Размер дерева от strength (1..100) === + // strength=10 → саженец (sizeScale=0.5) + // strength=50 → стандарт (sizeScale=1.0) + // strength=100 → большое (sizeScale=2.0) + const strength = brush.strength ?? 50; + const sizeScale = 0.5 + (strength / 100) * 1.5; + + // === Случайный тип дерева === + const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)]; + + // === Уникальный seed на каждое дерево — даёт разную форму === + const seed = (Math.random() * 0x7fffffff) | 0; + + // === Ставим voxels через batched setVoxel-fn === + // tm._addInstance не обновляет GPU buffer в batch-режиме, делаем + // один flushBatch в конце. Это превращает ~300 add'ов в один upload. + tm._beginBatch?.(); + let placed = 0; + try { + const setVoxelFn = (x, y, z, matId) => { + const k = `${x},${y},${z}`; + if (tm.voxels.has(k)) return; + tm._addInstance?.(k, x, y, z, matId); + tm.voxels.set(k, matId); + placed++; + }; + placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed); + } finally { + tm._flushBatch?.(); + } + if (placed > 0) { + try { tm._emit?.(); } catch (e) {} + try { this._onSceneChange?.(); } catch (e) {} + } + } + + /** Удалить все декорации в зоне кисти (по миру). */ + _eraseDecorationsInBrush(brush) { + if (!this.decoManager || !this.decoManager.placements) return; + const TERRAIN_VOXEL = 0.25; + const cx = (brush.x + 0.5) * TERRAIN_VOXEL; + const cz = (brush.z + 0.5) * TERRAIN_VOXEL; + const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства + const r2 = r * r; + const keep = []; + let removed = 0; + for (const p of this.decoManager.placements) { + const dx = p.x - cx; + const dz = p.z - cz; + if (dx * dx + dz * dz <= r2) { + removed++; + } else { + keep.push(p); + } + } + if (removed > 0) { + // Перезагружаем decoManager с обновлённым списком + this.decoManager.clear(); + this.decoManager.loadFromArray(keep); + try { this._onSceneChange?.(); } catch (e) {} + } + } + + _terrainHistoryEnsure() { + if (!this._terrainHistory) { + this._terrainHistory = { stack: [], cursor: -1, pending: null }; + } + return this._terrainHistory; + } + _terrainHistoryOpen() { + const tm = this.terrainManager; + if (!tm) return; + const h = this._terrainHistoryEnsure(); + // Снапшот до изменения + h.pending = tm.serialize(); + } + _terrainHistoryClose() { + const tm = this.terrainManager; + if (!tm) return; + const h = this._terrainHistoryEnsure(); + if (!h.pending) return; + const after = tm.serialize(); + // Сравниваем размером — если ничего не изменилось, не пушим + if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) { + h.pending = null; + return; + } + // Обрубаем все «впереди-курсора» (redo-стек после нового действия) + if (h.cursor < h.stack.length - 1) { + h.stack.length = h.cursor + 1; + } + h.stack.push(h.pending); + h.cursor = h.stack.length - 1; + // Ограничение 30 шагов + const MAX = 30; + while (h.stack.length > MAX) { + h.stack.shift(); + h.cursor--; + } + h.pending = null; + } + _terrainSerEqual(a, b) { + // Сравнение двух serialize-массивов. O(n) с использованием Set. + if (a.length !== b.length) return false; + const sa = new Set(); + for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`); + for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false; + return true; + } + undoTerrain() { + const tm = this.terrainManager; + if (!tm) return false; + const h = this._terrainHistoryEnsure(); + if (h.cursor < 0) return false; + // Текущий стейт в редо-позицию (cursor+1) + const current = tm.serialize(); + const target = h.stack[h.cursor]; + // Если на cursor лежит снапшот «до», нам нужно вернуться к нему. + // Cursor указывает на последнее ВОЗВРАТНОЕ состояние. + tm.loadFromArray(target); + // Записываем текущий стейт в позицию cursor+1 для возможного redo + h.stack[h.cursor + 1] = current; + h.cursor--; + return true; + } + redoTerrain() { + const tm = this.terrainManager; + if (!tm) return false; + const h = this._terrainHistoryEnsure(); + if (h.cursor + 1 >= h.stack.length - 0) return false; + const target = h.stack[h.cursor + 2]; + if (!target) return false; + tm.loadFromArray(target); + h.cursor++; + return true; + } + + // ======================================================================== + // Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать») + // + // Регион — это объёмная коробка, заданная двумя углами в voxel-индексах. + // Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле: + // первый mousedown в режиме «Выделить» — стартовый угол, drag — второй. + // Высота коробки фиксируется ±radius по Y вокруг плоскости клика. + // + // Регион используется: + // • «Заполнить» — залить регион выбранным материалом + // • «Преобразовать» — переместить все voxel'ы региона в новое место + // (плоский drag по XZ, без поворота на этапе 2). + // ======================================================================== + + /** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */ + getTerrainRegion() { return this._terrainRegion || null; } + + /** Очистить выделение и убрать визуализацию. */ + clearTerrainRegion() { + this._terrainRegion = null; + if (this._terrainRegionMesh) { + try { this._terrainRegionMesh.dispose(); } catch (e) {} + this._terrainRegionMesh = null; + } + } + + /** Обновить wireframe-визуализацию региона по this._terrainRegion. */ + _updateTerrainRegionVisual() { + const r = this._terrainRegion; + if (this._terrainRegionMesh) { + try { this._terrainRegionMesh.dispose(); } catch (e) {} + this._terrainRegionMesh = null; + } + if (!r) return; + + // eslint-disable-next-line global-require + const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); + const S = TERRAIN_VOXEL_SIZE; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + // Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно. + const sizeX = (maxX - minX + 1) * S; + const sizeY = (maxY - minY + 1) * S; + const sizeZ = (maxZ - minZ + 1) * S; + const cx = (minX + 0.5) * S + (sizeX - S) / 2; + const cy = (minY + 0.5) * S + (sizeY - S) / 2; + const cz = (minZ + 0.5) * S + (sizeZ - S) / 2; + + const mesh = MeshBuilder.CreateBox('__terrainRegion', { + width: sizeX, height: sizeY, depth: sizeZ, + }, this.scene); + mesh.position.set(cx, cy, cz); + mesh.isPickable = false; + const mat = new StandardMaterial('__terrainRegionMat', this.scene); + mat.wireframe = true; + mat.emissiveColor = new Color3(0.20, 0.55, 1.00); + mat.diffuseColor = new Color3(0, 0, 0); + mat.alpha = 0.9; + mesh.material = mat; + mesh.metadata = { _isTerrainRegion: true }; + this._terrainRegionMesh = mesh; + } + + /** Запустить выделение региона: pickStart — voxel-клетка начала. */ + _terrainBeginRegion(pickStart) { + const radius = this._terrainBrush?.brushSize || 4; + // Высота региона по умолчанию = ±radius от Y клика. При drag юзер + // может уточнить — но через первый MVP оставим фиксированной. + this._terrainRegion = { + x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z, + x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z, + }; + this._terrainRegionDragging = true; + this._updateTerrainRegionVisual(); + } + + /** Обновить второй угол региона по новой voxel-клетке. */ + _terrainUpdateRegion(pickEnd) { + if (!this._terrainRegion) return; + this._terrainRegion.x1 = pickEnd.x; + this._terrainRegion.z1 = pickEnd.z; + // Y оставляем как поставили при начале — drag по плоскости XZ + this._updateTerrainRegionVisual(); + } + + /** Завершить drag выделения. */ + _terrainEndRegion() { + this._terrainRegionDragging = false; + } + + /** + * Инициализировать пустой smooth-terrain для скульптинга с нуля. + * Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде. + * Первый клик sculpt-кистью сразу породит холм в нужном месте. + */ + _initEmptySmoothTerrain() { + if (this._robloxTerrain) { + try { this._robloxTerrain.disposeAll(); } catch (e) {} + } + this._robloxTerrain = new RobloxTerrain(this.scene); + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + const sx = 100, sy = 24, sz = 100; + const grid = new RobloxDensityGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + // Регистрируем стандартные материалы в палитре (нужно для brushes). + // Index 0 = пусто, далее по порядку для совместимости с web/Android. + for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) { + // Hack: set одной ячейки потом обнуляем, чтобы добавить в palette. + grid.set(0, 0, 0, 0, matKey); + } + // Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId + // последнего set'а. Обнуляем явно. + grid.densityData[0] = 0; + grid.matData[0] = 0; + // skipEmpty: true — НЕ добавляем 98 пустых chunks в pending, + // mesher будет работать только после первой кисти. + this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true }); + // НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать + // плоскость y=0 при первых кликах. Отключим когда появится хоть один + // solid chunk (см. updateStreaming / applyBrushAndRebuild). + console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)'); + } + + /** + * Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain, + * получает worldPosition и вызывает applyBrush в этой точке. + * + * Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt + * на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells + * (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio. + */ + _smoothBrushTick(shiftKey, isFirst) { + const tool = this._terrainBrush?.tool || 'sculpt'; + // === Инструмент «Выбрать деко» — поштучный клик-выбор декораций === + // Не модифицирует ландшафт, работает только на клик (не drag). + if (tool === 'pickDeco') { + if (isFirst) this._pickDecoTick(); + return; + } + const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid; + + // === Счётчики для диагностики === + if (!this._smoothBrushDiag) { + this._smoothBrushDiag = { + tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0, + applyResult: { built: 0, dirty0: 0 }, + }; + } + const D = this._smoothBrushDiag; + D.tickCount++; + const tickN = D.tickCount; + + // === Plane-lock + rate-limit (как в Roblox Studio) === + // При isFirst — фиксируем плоскость и позицию первого клика. + // Дальше при drag: + // 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом), + // а из зафиксированного _smoothBrushLockY → кисть работает в плоскости. + // 2) Между tick'ами требуется минимальное расстояние (0.6×radius) — + // иначе одна и та же точка → cells доходят до 255 → "какаха". + if (isFirst) { + this._smoothBrushLockY = null; + this._smoothBrushLastPos = null; + } + + // === Raycast — выбираем точку под курсором === + let hit = null; + let pickSource = ''; + if (!terrainEmpty) { + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + hit = this.scene.pickWithRay( + this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera), + pickPred, + ); + if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; } + } + if (!hit || !hit.hit || !hit.pickedPoint) { + const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, + (m) => m.name === 'editorGround'); + if (groundPick && groundPick.hit && groundPick.pickedPoint) { + hit = groundPick; + pickSource = 'ground'; + D.hitGround++; + } else { + D.hitNone++; + if (isFirst || tickN % 20 === 0) { + console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`); + } + return; + } + } + const worldPt = hit.pickedPoint; + + // === Инициализация пустого grid при первом клике sculpt/fill === + let initialized = false; + if (terrainEmpty) { + if (!isFirst) { + if (tickN <= 5 || tickN % 30 === 0) { + console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`); + } + return; + } + if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') { + console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`); + return; + } + console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`); + this._initEmptySmoothTerrain(); + initialized = true; + if (!this._robloxTerrain || !this._robloxTerrain.grid) { + console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`); + return; + } + } + + const material = this._terrainBrush?.material || 'grass'; + const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0); + // strength: slider 0..100 → реальная strength 60..400 для sculpt. + // Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells + // на краю кисти достигали threshold 128 за 40 тиков а не 128. + // Максимум 400 = мгновенно ставит cells в 255 (полная заливка). + const strengthSlider = this._terrainBrush?.strength ?? 50; + const strength = 60 + (strengthSlider / 100) * 340; + + let brushType; + if (shiftKey) { + brushType = 'sculptDown'; + } else { + switch (tool) { + case 'draw': brushType = 'sculptUp'; break; + case 'sculpt': brushType = 'sculptUp'; break; + case 'smooth': brushType = 'smooth'; break; + case 'paint': brushType = 'paint'; break; + case 'flatten': + if (isFirst) this._smoothFlattenTargetY = worldPt.y; + brushType = 'flatten'; + break; + case 'fill': brushType = 'fill'; break; + case 'erase': brushType = 'erase'; break; + // === Plant-кисти: добавление декораций === + case 'plantGrass': brushType = 'plantGrass'; break; + case 'plantFlower': brushType = 'plantFlower'; break; + case 'plantMushroom': brushType = 'plantMushroom'; break; + case 'plantTree': brushType = 'plantTree'; break; + default: brushType = 'sculptUp'; + } + } + + // === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики === + // Они НЕ модифицируют DensityGrid — добавляют thin-instance модели. + if (brushType.startsWith('plant')) { + return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst); + } + + // === Plane-lock: для sculpt-кистей фиксируем Y первого клика === + // Без этого при drag в одной XZ-точке raycast возвращает всё более + // высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ → + // рельеф летит на камеру ("какаха"). + // С lock'ом drag работает в горизонтальной плоскости фиксированной + // высоты первого клика, как в Roblox Studio. + const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown'; + let centerY = worldPt.y; + if (isSculptKind) { + if (this._smoothBrushLockY === null) { + this._smoothBrushLockY = worldPt.y; + } + centerY = this._smoothBrushLockY; + // Смещение от плоскости первого клика на radius×0.5 + // (вверх для sculptUp, вниз для sculptDown). + if (brushType === 'sculptUp') centerY += radius * 0.5; + else centerY -= radius * 0.5; + } + + // === Rate-limit: пропускаем tick если кисть не сдвинулась далеко === + // Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback + // loop в одной точке. + if (isSculptKind && !isFirst && this._smoothBrushLastPos) { + const dx = worldPt.x - this._smoothBrushLastPos.x; + const dz = worldPt.z - this._smoothBrushLastPos.z; + const minDist = radius * 0.6; + if (dx * dx + dz * dz < minDist * minDist) { + // Кисть в той же точке — пропускаем (но НЕ для isFirst). + return; + } + } + if (isSculptKind) { + this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; + } + + const params = { + center: { x: worldPt.x, y: centerY, z: worldPt.z }, + radius, + strength, + material, + targetY: this._smoothFlattenTargetY, + }; + const tApply0 = performance.now(); + const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params); + const tApply = performance.now() - tApply0; + if (built > 0) D.applyResult.built += built; + else D.applyResult.dirty0++; + + if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) { + const gridStats = this._robloxTerrain.grid + ? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}` + : 'no-grid'; + console.log( + `[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} ` + + `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) ` + + `r=${radius} mat=${material} ${gridStats} ` + + `→ built=${built} chunks in ${tApply.toFixed(0)}ms ` + + (initialized ? ' [INITIALIZED!]' : '') + + (built === 0 ? ' [NO CHANGE]' : ''), + ); + } + + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья). + * Не трогает DensityGrid — работает только с SmoothDecoManager. + * Shift = ластик (удалить декорации в радиусе). + */ + _smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) { + // Нужен SmoothDecoManager (создаём lazy при первом plant-клике) + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + this._smoothDecoManager.loadAll(); + } + // Rate-limit как в sculpt: пропускаем близкие тики + if (!isFirst && this._smoothBrushLastPos) { + const dx = worldPt.x - this._smoothBrushLastPos.x; + const dz = worldPt.z - this._smoothBrushLastPos.z; + const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие) + if (dx * dx + dz * dz < minDist * minDist) return; + } + this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; + + // Shift — ластик + if (shiftKey) { + const removed = this._smoothDecoManager.removeBrushDecoInRadius( + { x: worldPt.x, z: worldPt.z }, radius, + ); + if (removed > 0) { + console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); + // Пересинхронизировать tree-colliders в physics + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + try { this._onSceneChange?.(); } catch (e) {} + } + return; + } + + // Маппинг brushType → kind для SmoothDecoManager + const kindMap = { + plantGrass: 'grass', + plantFlower: 'flower', + plantMushroom: 'mushroom', + plantTree: 'tree', + }; + const kind = kindMap[brushType]; + if (!kind) return; + + // Количество инстансов за один тик зависит от типа. + // Трава густая, деревья редко. + const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 }; + const count = countMap[kind] || 3; + + // Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0). + const sampleSurfaceY = (x, z) => { + if (this.physics?._sampleRobloxSurface) { + const y = this.physics._sampleRobloxSurface(x, z); + if (y !== null) return y; + } + // Fallback на ground y=0 + return 0; + }; + + const result = this._smoothDecoManager.addBrushDeco({ + kind, + center: { x: worldPt.x, y: worldPt.y, z: worldPt.z }, + radius, + count, + sampleSurfaceY, + }); + const added = result.added || 0; + if (added > 0) { + console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); + // Если посадили деревья — пересинхронизировать tree-colliders + // в physics (полная переустановка через getAllTreeColliders). + if (kind === 'tree' && this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + try { this._onSceneChange?.(); } catch (e) {} + } + } + + /** Залить выделенный регион материалом. Используется инструментом + * «Заполнить» когда есть активный _terrainRegion. */ + _terrainFillRegion(matId) { + const r = this._terrainRegion; + const tm = this.terrainManager; + if (!r || !tm) return 0; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + let n = 0; + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const key = `${x},${y},${z}`; + if (tm.voxels.has(key)) continue; + tm.setVoxel(x, y, z, matId); + n++; + } + } + } + return n; + } + + /** Переместить весь регион на dx/dy/dz в voxel-клетках. */ + _terrainMoveRegion(dx, dy, dz) { + const r = this._terrainRegion; + const tm = this.terrainManager; + if (!r || !tm) return 0; + if (dx === 0 && dy === 0 && dz === 0) return 0; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + // Собираем содержимое региона + const collected = []; + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const m = tm.getVoxel(x, y, z); + if (!m) continue; + collected.push({ x, y, z, m }); + } + } + } + // Удаляем из старых позиций + for (const v of collected) tm.removeVoxel(v.x, v.y, v.z); + // Ставим в новые + let n = 0; + for (const v of collected) { + tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m); + n++; + } + // Сдвигаем сам регион + r.x0 += dx; r.x1 += dx; + r.y0 += dy; r.y1 += dy; + r.z0 += dz; r.z1 += dz; + this._updateTerrainRegionVisual(); + return n; + } + + /** Двигать preview-меш под курсор. Вызывается из mousemove. */ + _updateTerrainBrushPosition() { + if (this._activeTool !== 'terrain') return; + if (!this._terrainBrushPreview) return; + // === Smooth-режим: raycast по smooth-mesh, preview на surface === + if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) { + const ray = this.scene.createPickingRay( + this.scene.pointerX, this.scene.pointerY, + null, this.camera, + ); + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + const hit = this.scene.pickWithRay(ray, pickPred); + if (hit && hit.hit && hit.pickedPoint) { + this._terrainBrushPreview.position.copyFrom(hit.pickedPoint); + } + return; + } + // Voxel-режим (как было) + const cell = this._pickTerrainCell(false); + if (!cell) return; + const S = TERRAIN_VOXEL_SIZE; + this._terrainBrushPreview.position.set( + (cell.x + 0.5) * S, + (cell.y + 0.5) * S, + (cell.z + 0.5) * S, + ); + } + + /** Публичный сеттер: выбрать тип блока для постановки. */ + setActiveBlockType(blockTypeId) { + this._activeBlockType = blockTypeId; + } + + /** Публичный сеттер: выбрать тип модели для постановки. */ + setActiveModelType(modelTypeId) { + this._activeModelType = modelTypeId; + } + + /** Тип примитива для постановки (cube/sphere/...). */ + setActivePrimitiveType(typeId) { + this._activePrimitiveType = typeId; + } + + getPrimitiveCount() { + return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0; + } + + /** Количество блоков (для status bar). */ + getBlockCount() { + return this.blockManager ? this.blockManager.count() : 0; + } + + /** Количество моделей-инстансов. */ + getModelCount() { + return this.modelManager ? this.modelManager.getInstanceCount() : 0; + } + + /** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */ + setOnSelectionChange(cb) { + if (this.selection) { + // Объединяем со внутренней подпиской на gizmo + this.selection.setOnSelectionChange((sel) => { + this._updateGizmoForSelection(sel); + if (cb) cb(sel); + }); + } + } + + /** Текущее выделение (или null). */ + getSelection() { + return this.selection?.getSelection() || null; + } + + /** Выделить блок программно (например по клику в Hierarchy). */ + selectBlockAt(x, y, z) { + this.selection?.selectBlockAt(x, y, z); + } + + /** Выделить модель программно. */ + selectModelByInstanceId(id) { + this.selection?.selectModelByInstanceId(id); + } + + /** Снять выделение. */ + clearSelection() { + this.selection?.clear(); + } + + /** Удалить выделенный объект. */ + deleteSelected() { + this.selection?.deleteSelected(); + } + + /** + * Дублировать выделенный объект (Ctrl+D). + * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). + * Модель: создаёт копию со смещением +1 по X. + */ + duplicateSelected() { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + // Ищем свободную клетку рядом + const candidates = [ + [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0], + ]; + for (const [dx, dy, dz] of candidates) { + const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz; + if (ny < 0) continue; + if (!this.blockManager.hasBlock(nx, ny, nz)) { + this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); + this.selection.selectBlockAt(nx, ny, nz); + return; + } + } + } else if (sel.type === 'model') { + // Сохраняем все нужные поля выделения до того как промис завершится + // (selection может перезатереться к моменту resolve) + const typeId = sel.modelTypeId; + const sx = sel.x, sy = sel.y, sz = sel.z; + const rotY = sel.rotationY || 0; + this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) + .then(newId => { + if (newId != null) this.selection?.selectModelByInstanceId(newId); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error('[BabylonScene] duplicate model error:', err); + }); + } else if (sel.type === 'userModel') { + const typeId = sel.userModelTypeId; + const sx = sel.x, sy = sel.y, sz = sel.z; + const rotY = sel.rotationY || 0; + this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { + currentUserId: this._currentUserId || null, + }).then(newId => { + if (newId != null) this.selection?.selectUserModelByInstanceId(newId); + }).catch(err => { + console.error('[BabylonScene] duplicate user model error:', err); + }); + } else if (sel.type === 'primitive') { + const newId = this.primitiveManager.addInstance(sel.primitiveType, { + x: sel.x + 1, y: sel.y, z: sel.z, + sx: sel.sx, sy: sel.sy, sz: sel.sz, + color: sel.color, material: sel.material, + canCollide: sel.canCollide, visible: sel.visible, + anchored: sel.anchored, + // Копируем и спец-свойства: текстуру, параметры лампы/эмиттера. + textureAsset: sel.textureAsset || null, + brightness: sel.brightness, range: sel.range, effect: sel.effect, + }); + if (newId != null) this.selection.selectPrimitiveById(newId); + } + } + + /** + * Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10). + * Буфер — localStorage, поэтому переживает перезагрузку страницы + * и смену проекта (Copy/Paste между проектами). + */ + copySelected() { + const sel = this.selection?.getSelection(); + if (!sel) return; + let clip = null; + if (sel.type === 'block') { + clip = { kind: 'block', blockTypeId: sel.blockTypeId }; + } else if (sel.type === 'model') { + clip = { + kind: 'model', modelTypeId: sel.modelTypeId, + rotationY: sel.rotationY || 0, scale: sel.scale || 1, + }; + } else if (sel.type === 'userModel') { + clip = { + kind: 'userModel', userModelTypeId: sel.userModelTypeId, + rotationY: sel.rotationY || 0, + }; + } else if (sel.type === 'primitive') { + clip = { + kind: 'primitive', primitiveType: sel.primitiveType, + sx: sel.sx, sy: sel.sy, sz: sel.sz, + color: sel.color, material: sel.material, + canCollide: sel.canCollide, visible: sel.visible, + anchored: sel.anchored, + textureAsset: sel.textureAsset || null, + brightness: sel.brightness, range: sel.range, effect: sel.effect, + }; + } + if (clip) { + try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } + catch (e) { /* ignore — приватный режим / переполнение */ } + } + } + + /** + * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). + * Объект появляется у точки, куда смотрит редактор-камера. + */ + pasteFromClipboard() { + let clip; + try { + const raw = localStorage.getItem('kubikon_clipboard'); + if (!raw) return; + clip = JSON.parse(raw); + } catch (e) { return; } + if (!clip || !clip.kind) return; + // Точка вставки — перед редактор-камерой (~6м по направлению взгляда). + const cam = this.camera; + let px = 0, py = 1, pz = 0; + if (cam) { + const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null; + if (fwd) { + px = cam.position.x + fwd.x * 6; + pz = cam.position.z + fwd.z * 6; + } + } + if (clip.kind === 'block') { + const gx = Math.round(px), gz = Math.round(pz); + let gy = 0; + while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; + this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); + this.selection?.selectBlockAt(gx, gy, gz); + } else if (clip.kind === 'model') { + this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) + .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) + .catch(() => {}); + } else if (clip.kind === 'userModel') { + this.userModelManager?.addInstance( + clip.userModelTypeId, px, py, pz, clip.rotationY || 0, + { currentUserId: this._currentUserId || null }, + ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) + .catch(() => {}); + } else if (clip.kind === 'primitive') { + const id = this.primitiveManager?.addInstance(clip.primitiveType, { + x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, + sx: clip.sx, sy: clip.sy, sz: clip.sz, + color: clip.color, material: clip.material, + canCollide: clip.canCollide, visible: clip.visible, + anchored: clip.anchored, + textureAsset: clip.textureAsset || null, + brightness: clip.brightness, range: clip.range, effect: clip.effect, + }); + if (id != null) this.selection?.selectPrimitiveById(id); + } + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Поставить выделенный объект на пол (y = 0). + * Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2. + */ + alignSelectedToFloor() { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ); + } else if (sel.type === 'model') { + // Модель: rootMesh.position.y = низ модели. y=0 = низ на полу. + this.selection.moveSelectedModel(sel.x, 0, sel.z); + } else if (sel.type === 'userModel') { + this.selection.moveSelectedUserModel(sel.x, 0, sel.z); + } else if (sel.type === 'primitive') { + // Центр примитива должен быть на высоте sy/2 чтобы низ касался пола. + const halfH = (sel.sy || 1) / 2; + this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z); + } + } + + /** + * View-preset — поставить редактор-камеру в одну из стандартных позиций. + * preset: 'top' | 'front' | 'side' | 'iso' + */ + setViewPreset(preset) { + if (!this.camera) return; + const presets = { + top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху + front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди + side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку + iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия + }; + const p = presets[preset]; + if (!p) return; + this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]); + this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]); + } + + /** + * Поставить точку спавна там где сейчас смотрит редактор-камера + * (полезно для размещения «тут начинать игру»). + * spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z). + */ + setSpawnAtCamera() { + if (!this.camera) return; + const p = this.camera.position; + this._spawnPoint = { + x: Math.round(p.x), + y: Math.max(0, Math.floor(p.y) - 1), + z: Math.round(p.z), + }; + this._updateSpawnMarker(); + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** Изменить позицию выделенного (используется Inspector). */ + moveSelectedTo(x, y, z) { + if (!this.selection) return; + const sel = this.selection.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + this.selection.moveSelectedBlock(Math.round(x), Math.round(y), Math.round(z)); + } else if (sel.type === 'model') { + this.selection.moveSelectedModel(x, y, z); + } else if (sel.type === 'userModel') { + this.selection.moveSelectedUserModel(x, y, z); + } else if (sel.type === 'spawn') { + this.selection.moveSelectedSpawn(x, y, z); + } else if (sel.type === 'primitive') { + this.selection.moveSelectedPrimitive(x, y, z); + } + } + + /** Изменить размер выделенного примитива (Inspector). */ + resizeSelectedPrimitiveTo(sx, sy, sz) { + this.selection?.resizeSelectedPrimitive(sx, sy, sz); + } + + /** Изменить свойства выделенного примитива (color/material/canCollide/visible). */ + setSelectedPrimitivePropsTo(patch) { + this.selection?.setSelectedPrimitiveProps(patch); + } + + /** Повернуть выделенную модель (Y, в радианах). */ + rotateSelectedModelTo(angleRad) { + if (!this.selection) return; + const sel = this.selection.getSelection(); + if (sel?.type === 'userModel') { + this.selection.rotateSelectedUserModel(angleRad); + } else { + this.selection.rotateSelectedModel(angleRad); + } + } + + /** Изменить масштаб выделенной модели. */ + scaleSelectedModelTo(scale) { + if (!this.selection) return; + const sel = this.selection.getSelection(); + if (sel?.type === 'userModel') { + this.selection.scaleSelectedUserModel(scale); + } else { + this.selection.scaleSelectedModel(scale); + } + } + + /** Установить режим гизмо: 'select' | 'move' | 'rotate' | 'scale'. */ + setGizmoMode(mode) { + if (this._gizmo) this._gizmo.setMode(mode); + } + + /** Получить текущий режим гизмо. */ + getGizmoMode() { + return this._gizmo ? this._gizmo.getMode() : 'select'; + } + + /** Установить snap-step гизмо для перемещения (1.0 / 0.5 / 0.25 / 0=off). + * Также применяется к Inspector-вводу координат моделей. */ + setGizmoSnap(step) { + if (this._gizmo) this._gizmo.setSnap(step); + if (this.selection) this.selection.setSnapStep(step); + } + + getGizmoSnap() { + return this._gizmo ? this._gizmo.getSnap() : 0; + } + + /** Сфокусировать редактор-камеру на выделенном (двигает камеру к объекту). */ + focusOnSelection() { + const sel = this.selection?.getSelection(); + if (!sel) return; + let target; + if (sel.type === 'block') { + target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ); + } else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') { + target = new Vector3(sel.x, sel.y + 0.5, sel.z); + } + if (target) this._focusOnTarget(target); + } + + /** Установить точку спавна игрока в режиме Play. */ + setSpawnPoint(x, y, z) { + this._spawnPoint = { x, y, z }; + this._updateSpawnMarker(); + } + + /** Установить тип модели персонажа (для Play). */ + setPlayerModelType(typeId) { + if (!typeId) return; + this._playerModelType = typeId; + } + + getPlayerModelType() { + return this._playerModelType; + } + + /** Идёт ли сейчас режим игры. */ + isPlaying() { + return this._isPlaying; + } + + /** + * Переключить в режим игры. Создаём PlayerController, прячем ghost-блок, + * запоминаем позицию редактор-камеры чтобы вернуть при exit. + */ + enterPlayMode() { + if (this._isPlaying) return; + this._isPlaying = true; + // Сброс состояния касаний — каждый прогон начинается «не касаясь». + if (this._touchState) this._touchState.clear(); + this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play + // По умолчанию стандартный HUD видим в Play. + // Скрипт может скрыть через game.hud.setVisible(false). + this._setStdHudVisible(true); + + // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld + // не «видит» воксели в Ray-каст и пролетает сквозь стены. + try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {} + + // Снимок редактор-камеры + this._editorCameraSnapshot = { + position: this.camera.position.clone(), + rotation: this.camera.rotation.clone(), + }; + + if (this._ghostMesh) this._ghostMesh.setEnabled(false); + this._setSpawnMarkerVisible(false); + // Триггеры — невидимые в Play, видимые в редакторе + this.primitiveManager?.setTriggersVisible(false); + + // Запоминаем исходные позиции unanchored-объектов чтобы вернуть + // их при выходе из Play (физика двигает mesh.position). + this._snapshotDynamicObjects(); + // Полный снимок примитивов и моделей — чтобы при Stop откатить + // ВСЕ изменения скриптов (удаления, цвет, видимость, повороты). + this._snapshotFullScene(); + + // Запускаем физику unanchored + this.dynamics?.start(); + + // Запускаем фоновую музыку и амбиент + this.audioManager?.start(); + + // Создаём PlayerController и стартуем + this.player = new PlayerController(this.scene, this.canvas, this.physics, this); + this.player.setModelType(this._playerModelType); + // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck + try { + this.modalManager?.attachPlayer?.(this.player); + this.modalManager?.attachAudio?.(this.audioManager); + } catch (e) {} + this.player._jumpPowerMul = this._jumpPowerMul ?? 1; + // Применяем дефолтную камеру если задана в сцене + if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { + this.player._cameraMode = this._defaultCameraMode; + } + // На тач-устройствах отключаем pointer-lock и mouse-камеру + if (this._touchMode) this.player.setTouchMode(true); + this.player.setOnExitRequest(() => { + // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). + if (this._skinShop?.open) { + this._closeSkinShop(); + return; + } + // Задача 04: если открыт модал — первый Esc закрывает его, + // второй Esc уже выходит из Play. Так юзер не теряет состояние игры + // случайно при попытке скрыть модал. + if (this.modalManager?.isOpen?.()) { + this.modalManager.close(); + return; + } + // ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox). + // Единый источник истины — _playerMenuOpen в движке. Раньше состояние + // меню держал React, а ESC слушали ДВА обработчика (движок + React) → + // гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true + // → orbit-камера по ПКМ переставала работать после закрытия меню. + // Теперь движок сам решает open/close и шлёт это в _onEscMenu(open). + if (typeof this._onEscMenu === 'function') { + if (this._playerMenuOpen) { + // Меню открыто → ESC закрывает: вернуть мышь в игру. + this._playerMenuOpen = false; + this.player?.setUiCursorMode?.(false); + this._onEscMenu(false); + } else { + // Меню закрыто → ESC открывает: освободить курсор. + this._playerMenuOpen = true; + this.player?.setUiCursorMode?.(true); + this._onEscMenu(true); + } + return; + } + // Фолбэк (если меню не подписано, напр. в студии) — старое поведение. + this.exitPlayMode(); + if (this._onPlayChange) this._onPlayChange(false); + }); + if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); + if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); + this.player.start(this._spawnPoint); + + // Запускаем пользовательские скрипты (этап 2.1). + // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, + // поэтому скрипты стартуем в следующем кадре. + this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. + // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным + // this.audioManager (AudioManager — ambient/music для всех проектов). + if (!this.gameAudioManager) { + this.gameAudioManager = new GameAudioManager(); + } + // GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты. + // Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе. + if (!this.gdLevelManager) { + this.gdLevelManager = new GdLevelManager(this); + this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {} + }); + this.gdLevelManager.setOnDeath((info) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {} + // Респавн игрока через teleport на spawnPoint + try { + const sp = this._spawnPoint || { x: 0, y: 2, z: 0 }; + this.player.teleport(sp.x, sp.y, sp.z); + // Сбросить vy чтобы не нести инерцию из шипа + if (this.player) this.player._vy = 0; + } catch (e) {} + }); + this.gdLevelManager.setOnFinish((info) => { + const stats = this.gdLevelManager.getCoinsStats(); + try { + this.gameRuntime?.routeGlobalEvent?.('message', { + name: 'gdFinish', + data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total }, + }); + } catch (e) {} + try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {} + }); + this.gdLevelManager.setOnCoinCollected((info) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {} + }); + } + this.gdLevelManager.start(); + // Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней. + // Откладываем на setTimeout — primitiveManager.instances наполняется + // не сразу при enterPlayMode (load.primitives асинхронный). + // GD-проект определяется флагом settings.isGd (см. serialize/loadFromState). + // Fallback для старых проектов БЕЗ флага — реальные id GD-уровней: + // - 295: GD 2.0 sandbox + // - 296..306: L1-L11 (эпоха 1 + L11 легаси) + // - 350..358: L12-L20 (эпоха 2) + // Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали, + // и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb, + // нет skybox/forest на заднем фоне). На APK работало правильно. + const _pid = Number(this._currentProjectId); + const isGd = (typeof this._isGdProject === 'boolean') + ? this._isGdProject + : ((_pid >= 296 && _pid <= 315) + || (_pid >= 350 && _pid <= 358) + || _pid === 295); + console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`); + if (isGd) { + // Ширина уровня — по самому правому cube-блоку + let levelWidth = 1000; + if (this.blockManager && this.blockManager.blocks) { + for (const key of this.blockManager.blocks.keys()) { + const x = parseInt(String(key).split(',')[0], 10); + if (Number.isFinite(x) && x > levelWidth) levelWidth = x; + } + } + setTimeout(() => { + try { + if (!this.gdSkybox) { + this.gdSkybox = new GdSkybox(); + const cam = this.player?.camera || this.scene.activeCamera; + this.gdSkybox.attach(this.scene, cam); + console.log('[GD-gfx] skybox attached'); + } + if (!this.gdGroundSkin) { + this.gdGroundSkin = new GdGroundSkin(); + this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this); + console.log('[GD-gfx] groundSkin attached, width=', levelWidth); + } + // Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358. + const pid = Number(this._currentProjectId) || 296; + const GD_PID_TO_EPOCH = { + 296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1, + 306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2, + }; + const epoch = GD_PID_TO_EPOCH[pid] || 1; + if (!this.gdSpikes) { + this.gdSpikes = new GdSpikes(); + this.gdSpikes.attach(this.scene, this, epoch); + } + if (!this.gdStartArch) { + this.gdStartArch = new GdStartArch(); + this.gdStartArch.attach(this.scene, epoch); + } + if (!this.gdPortalArch) { + this.gdPortalArch = new GdPortalArch(); + this.gdPortalArch.attach(this.scene, this, this._currentUserId); + } + if (!this.gdDiamond) { + this.gdDiamond = new GdDiamond(); + this.gdDiamond.attach(this.scene, this); + } + if (!this.gdFinish) { + this.gdFinish = new GdFinish(); + this.gdFinish.attach(this.scene, this, epoch); + } + if (!this.gdForest) { + this.gdForest = new GdForest(); + this.gdForest.attach(this.scene, levelWidth, epoch); + } + if (!this.gdPlayerCube) { + this.gdPlayerCube = new GdPlayerCube(); + this.gdPlayerCube.attach(this.scene, this); + } + if (!this.gdPlayerModeSkin) { + // Задержка 600мс — даём скрипту уровня применить базовый cube-skin, + // чтобы _origTexture при первой смене режима содержала правильную текстуру. + setTimeout(() => { + this.gdPlayerModeSkin = new GdPlayerModeSkin(); + this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId); + }, 600); + } + if (!this.gdPlayerTrail) { + this.gdPlayerTrail = new GdPlayerTrail(); + this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId); + } + if (!this.gdPostFx) { + this.gdPostFx = new GdPostFx(); + const cam = this.player?.camera || this.scene.activeCamera; + this.gdPostFx.attach(this.scene, cam, this); + } + // Тени отключены — делаем через GdGroundSkin (fake shadows) + this._enableGdShadows(); + } catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); } + }, 50); + } + if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); + if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); + if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); + // eslint-disable-next-line no-console + console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); + // Старт через requestAnimationFrame — даём Babylon собрать сцену + requestAnimationFrame(() => { + if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); + }); + + // === Оружие === + if (!this.weapons) this.weapons = new WeaponSystem(this); + if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange); + // Подключаем зомби-логику к попаданиям пули + this.weapons.setOnHit((hit) => { + if (hit?.mesh && this.zombieManager) { + this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); + } + if (this._onWeaponHit) { + try { this._onWeaponHit(hit); } catch (e) {} + } + }); + this.weapons.start(); + + // === Зомби-система === + if (!this.zombieManager) this.zombieManager = new ZombieManager(this); + if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager); + this.zombieManager.start(); + this.spawnerManager.start(); + + // === NPC-система (Фаза 4.1) — управляемые скриптом персонажи === + if (!this.npcManager) this.npcManager = new NpcManager(this); + this.npcManager.start(); + + // === Связи объектов (Фаза 5, Constraints) === + if (!this.constraintManager) this.constraintManager = new ConstraintManager(this); + this.constraintManager.start(); + + // === Лучи и следы (Фаза 5.2 — Beam/Trail) === + if (!this.beamManager) this.beamManager = new BeamManager(this); + this.beamManager.start(); + // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки. + this._activatePointers(); + + // === 3D-звук (Фаза 5.5 — позиционный звук) === + if (!this.soundManager) this.soundManager = new SoundManager(this); + this.soundManager.start(); + // Регистрируем gameplay-объекты: зомби и спавнеры. + // Применяем defaults + текущие gameplayParams из инспектора. + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + const gp = data.gameplay; + if (!gp) continue; + const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) }; + if (gp.isZombie) { + this.zombieManager.registerExisting(data.instanceId, params); + } else if (gp.isZombieSpawner) { + this.spawnerManager.register(data.instanceId, params); + } + } + } + // Снаряжаем оружие из активного слота инвентаря + const active = this.inventory?.getActive?.(); + if (active) this.weapons.equip(active); + + // Замораживаем world-matrix у всех статичных GLB-моделей + // (не зомби и не спавнеры). Деревья, дома, камни не двигаются — + // Babylon не должен пересчитывать их матрицы каждый кадр. + try { this.modelManager?.freezeStaticModels?.(); } catch (e) {} + try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {} + + // ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн. + // Octree создаётся один раз и не «знает» о мешах добавленных позже — + // даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов, + // debris-кубы при смерти, динамические объекты) фактически выпадают + // из активного списка → невидимы. Стандартный frustum-culling Babylon + // дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы. + } + + /** Заглушка для совместимости — раньше пересоздавала octree. */ + setActiveMeshesDirty() { + // no-op + } + + /** Установить колбэк логов от скриптов (для Console-панели UI). */ + setOnScriptLog(cb) { + this._onScriptLog = cb; + if (this.gameRuntime) this.gameRuntime.setOnLog(cb); + } + + /** Колбэк команд HUD от скриптов (для GameHud React-компонента). */ + setOnScriptHud(cb) { + this._onScriptHud = cb; + if (this.gameRuntime) this.gameRuntime.setOnHud(cb); + } + + /** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */ + setOnScriptCrosshair(cb) { + this._onScriptCrosshair = cb; + if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb); + } + + // ============================================================ + // Таймер прохождения (для лидерборда) + // ============================================================ + /** cb({state: 'start'|'stop'|'submit', timeMs}) */ + setOnTimer(cb) { this._onTimer = cb; } + + _timerStart() { + this._timerStartedAt = performance.now(); + this._timerRunning = true; + if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {} + } + _timerStop() { + if (!this._timerRunning) return; + const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); + this._timerRunning = false; + if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {} + } + _timerSubmit() { + if (!this._timerRunning && !this._timerStartedAt) return; + const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); + this._timerRunning = false; + if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {} + } + /** Получить текущее время таймера в мс (или 0 если не запущен). */ + getTimerMs() { + if (!this._timerRunning || !this._timerStartedAt) return 0; + return Math.round(performance.now() - this._timerStartedAt); + } + isTimerRunning() { return !!this._timerRunning; } + + /** PERF-METRICS: получить и сбросить накопленные метрики за окно. */ + flushPerfMetrics() { + const m = this._perfMetrics; + if (!m) return null; + const out = { + render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0, + physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0, + script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0, + idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0, + render_count: m.render_count, + physics_count: m.physics_count, + script_count: m.script_count, + }; + m.render_ms_sum = 0; m.render_count = 0; + m.physics_ms_sum = 0; m.physics_count = 0; + m.script_ms_sum = 0; m.script_count = 0; + m.idle_ms_sum = 0; m.idle_count = 0; + return out; + } + + /** + * Поставить render-loop на паузу. + * Используется когда Babylon canvas не виден (активен таб скрипта), + * чтобы освободить CPU/GPU и Monaco не лагал. + * НЕ останавливает Play-режим — только рендер. + */ + pauseRendering() { this._renderingPaused = true; } + resumeRendering() { this._renderingPaused = false; } + isRenderingPaused() { return !!this._renderingPaused; } + + /** + * Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles + * (через game.scene.spawnParticles в скриптах). + * + * payload: { type, position: {x,y,z}, duration, count, color } + */ + _spawnParticleEffect(payload) { + if (!payload || !this.scene) return; + const pos = payload.position || { x: 0, y: 0, z: 0 }; + const type = payload.type || 'sparks'; + const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5)); + const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1)); + + // Кэшируем текстуру частицы — один раз на сцену + if (!this._particleTex) { + const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); + const ctx = tex.getContext(); + const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 64); + tex.update(); + tex.hasAlpha = true; + this._particleTex = tex; + } + + // MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза + const baseCount = this._isMobileMode ? 40 : 80; + const ps = new ParticleSystem('p_' + Date.now(), + Math.floor(baseCount * countMul), this.scene); + ps.particleTexture = this._particleTex; + ps.emitter = new Vector3(pos.x, pos.y, pos.z); + ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); + ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); + ps.blendMode = ParticleSystem.BLENDMODE_ADD; + + const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color) + ? payload.color : null; + this._configureParticleSystem(ps, type, customColor, countMul); + + ps.start(); + // Авто-стоп: для explosion почти сразу (это burst), для остальных = duration + const stopAt = type === 'explosion' ? 0.05 : duration; + const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3; + setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000); + // dispose(false) — particleTexture расшарена (_particleTex), не удалять. + setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000); + } + + /** + * Настроить параметры ParticleSystem под тип эффекта. + * Общий конфигуратор для разового эффекта (_spawnParticleEffect) и + * постоянного эмиттера-объекта (createEmitterParticles). + */ + _configureParticleSystem(ps, type, customColor, countMul = 1) { + const hexToColor4 = (hex, a = 1) => { + const r = parseInt(hex.substr(1, 2), 16) / 255; + const g = parseInt(hex.substr(3, 2), 16) / 255; + const b = parseInt(hex.substr(5, 2), 16) / 255; + return new Color4(r, g, b, a); + }; + + switch (type) { + case 'fire': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1); + ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1); + ps.colorDead = new Color4(0.2, 0, 0, 0); + ps.minSize = 0.2; ps.maxSize = 0.5; + ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; + ps.emitRate = 80; + ps.gravity = new Vector3(0, 1.2, 0); + ps.direction1 = new Vector3(-0.4, 1.5, -0.4); + ps.direction2 = new Vector3(0.4, 2.0, 0.4); + ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2; + break; + case 'smoke': + ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6); + ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.4; ps.maxSize = 1.2; + ps.minLifeTime = 1.5; ps.maxLifeTime = 3; + ps.emitRate = 40; + ps.gravity = new Vector3(0, 0.5, 0); + ps.direction1 = new Vector3(-0.3, 1, -0.3); + ps.direction2 = new Vector3(0.3, 1.5, 0.3); + ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7; + ps.blendMode = ParticleSystem.BLENDMODE_STANDARD; + break; + case 'sparks': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1); + ps.colorDead = new Color4(0.5, 0.2, 0, 0); + ps.minSize = 0.05; ps.maxSize = 0.15; + ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8; + ps.emitRate = 200; + ps.gravity = new Vector3(0, -8, 0); + ps.direction1 = new Vector3(-3, 4, -3); + ps.direction2 = new Vector3(3, 7, 3); + ps.minEmitPower = 1; ps.maxEmitPower = 3; + break; + case 'magic': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1); + ps.colorDead = new Color4(0.2, 0, 0.5, 0); + ps.minSize = 0.15; ps.maxSize = 0.35; + ps.minLifeTime = 1; ps.maxLifeTime = 2.2; + ps.emitRate = 60; + ps.gravity = new Vector3(0, 0.3, 0); + ps.direction1 = new Vector3(-1, 1, -1); + ps.direction2 = new Vector3(1, 2, 1); + ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5; + break; + case 'explosion': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1); + ps.colorDead = new Color4(0.2, 0, 0, 0); + ps.minSize = 0.3; ps.maxSize = 0.8; + ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; + ps.emitRate = 0; + ps.manualEmitCount = Math.floor(120 * countMul); + ps.gravity = new Vector3(0, -3, 0); + ps.direction1 = new Vector3(-5, -1, -5); + ps.direction2 = new Vector3(5, 5, 5); + ps.minEmitPower = 2; ps.maxEmitPower = 6; + break; + case 'confetti': + ps.color1 = new Color4(1, 0.3, 0.3, 1); + ps.color2 = new Color4(0.3, 0.6, 1, 1); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.1; ps.maxSize = 0.25; + ps.minLifeTime = 1.5; ps.maxLifeTime = 3; + ps.emitRate = 100; + ps.gravity = new Vector3(0, -3, 0); + ps.direction1 = new Vector3(-3, 5, -3); + ps.direction2 = new Vector3(3, 8, 3); + ps.minEmitPower = 1; ps.maxEmitPower = 3; + break; + default: + ps.color1 = new Color4(1, 1, 1, 1); + ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.1; ps.maxSize = 0.3; + ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5; + ps.emitRate = 60; + ps.direction1 = new Vector3(-1, 1, -1); + ps.direction2 = new Vector3(1, 2, 1); + } + } + + /** + * Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.). + * Не имеет авто-стопа — горит пока объект существует. Возвращает ps. + */ + createEmitterParticles(type, position, color) { + if (!this.scene) return null; + if (!this._particleTex) { + const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); + const ctx = tex.getContext(); + const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 64); + tex.update(); + tex.hasAlpha = true; + this._particleTex = tex; + } + const baseCount = this._isMobileMode ? 60 : 120; + const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene); + ps.particleTexture = this._particleTex; + ps.emitter = new Vector3(position.x, position.y, position.z); + ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15); + ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15); + ps.blendMode = ParticleSystem.BLENDMODE_ADD; + const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null; + // explosion как постоянный эффект не имеет смысла → fire + const effType = type === 'explosion' ? 'fire' : type; + this._configureParticleSystem(ps, effType, customColor, 1); + ps.start(); + return ps; + } + + /** + * Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора). + * Если runtime уже есть — переиспользуем, иначе создаём. + */ + startSoloScript(scriptId) { + const all = this._scripts || []; + const sc = all.find(s => s.id === scriptId); + if (!sc) return false; + if (!this.gameRuntime) { + this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + if (!this.gameAudioManager) { + this.gameAudioManager = new GameAudioManager(); + } + if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); + if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); + if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); + } + this.gameRuntime.startSolo(sc); + return true; + } + + /** Остановить отладочный (solo) запуск. */ + stopSoloScript() { + if (this.gameRuntime && this.gameRuntime.isSolo?.()) { + this.gameRuntime.stop(); + // Если не в Play-режиме — освобождаем runtime + if (!this._isPlaying) { + this.gameRuntime = null; + } + } + } + + isSoloRunning() { + return !!this.gameRuntime?.isSolo?.(); + } + getSoloScriptId() { + return this.gameRuntime?.getSoloScriptId?.() || null; + } + + /** Получить все скрипты проекта. */ + getScripts() { return [...this._scripts]; } + + /** Заменить все скрипты (используется при load/edit). */ + setScripts(scripts) { + this._scripts = Array.isArray(scripts) ? scripts.slice() : []; + } + + /** Установить код одного скрипта по id. Если id нет — создать новый. */ + upsertScript(id, code, target = undefined) { + const i = this._scripts.findIndex(s => s.id === id); + if (i >= 0) { + this._scripts[i] = { + ...this._scripts[i], + code, + ...(target !== undefined ? { target } : {}), + }; + } else { + this._scripts.push({ + id: id || `script_${Date.now()}`, + code, + target: target !== undefined ? target : null, + }); + } + // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит + // _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт. + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** Удалить скрипт по id. */ + removeScript(id) { + this._scripts = this._scripts.filter(s => s.id !== id); + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Зарегистрировать колбэк для уведомлений об изменении режима Play + * (вызывается когда player сам инициирует exit, например по Esc). + * KubikonEditor подписывается чтобы синхронизировать React-state. + */ + setOnPlayChange(cb) { + this._onPlayChange = cb; + } + + /** + * Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры + * БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см. + * setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC + * по-прежнему выходит из Play. + */ + setOnEscMenu(cb) { + this._onEscMenu = cb; + } + + /** + * Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают + * НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить + * движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит, + * что меню «открыто», и не откроет его. open=false также возвращает мышь в игру. + */ + setPlayerMenuOpen(open) { + const v = !!open; + if (this._playerMenuOpen === v) return; + this._playerMenuOpen = v; + if (!v) { + // меню закрыли из UI → вернуть управление камерой/мышью + try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ } + } + } + + /** + * Колбэк изменения сцены (любая модификация блоков/моделей). + * Используется KubikonEditor для dirty-tracking → auto-save. + * Сами обработчики на blockManager/modelManager привязаны в init() — + * они дёргают и history.markChange() и this._onSceneChange. + */ + setOnSceneChange(cb) { + this._onSceneChange = cb; + } + + /** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */ + setOnGuiChange(cb) { + this._onGuiChange = cb; + } + + /** Подключить API для пользовательских моделей (Kubikon3DService). + * Нужно дважды: setApi для самого UserModelManager (getUserModel) + * и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */ + setUserModelsApi(api) { + this._userModelsApi = api; + if (this.userModelManager && api) { + this.userModelManager.setApi(api); + } + } + + /** Передать id текущего пользователя — для запросов к приватным моделям. */ + setCurrentUserId(userId) { + this._currentUserId = userId; + } + + /** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API). + * Без этого скрипты не смогут сохранять прогресс. */ + setCurrentProjectId(projectId) { + this._currentProjectId = projectId; + } + + /** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...). + * Редактор/плеер подписываются и реактивно скрывают/показывают элементы. + * Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */ + setOnStdHudVisibilityChange(cb) { + this._onStdHudVisibilityChange = cb; + } + _setStdHudVisible(visible) { + this._stdHudVisible = !!visible; + 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. + * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ + setOnCursorModeChange(cb) { + this._onCursorModeChange = cb; + } + + /** Пересобрать spatial-индекс физики для user-моделей. + * Вызывается из SelectionManager при изменении canCollide / anchored / + * position / rotation / scale. */ + _syncUserModelColliders() { + try { this.physics?.setSpatialDirty?.(); } catch (e) {} + } + + /** Инвалидировать пользовательскую модель после её редактирования. + * Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с + * новой геометрией. Вызывается из KubikonEditor.jsx после закрытия + * редактора модели (когда editingUserModelId != null). */ + async refreshUserModel(userModelId) { + if (!this.userModelManager) return 0; + const rebuilt = await this.userModelManager.invalidateModel(userModelId, { + rebuild: true, + currentUserId: this._currentUserId || null, + }); + // Тени для свежесозданных мешей + if (rebuilt > 0) { + for (const inst of this.userModelManager.instances.values()) { + if (inst.userModelId === userModelId) { + for (const m of inst.meshes) { + try { this.addShadowCaster(m); } catch (e) {} + } + } + } + this._syncUserModelColliders(); + } + return rebuilt; + } + + /** Колбэк изменения инвентаря (для hot-bar React). */ + setOnInventoryChange(cb) { + this._onInventoryChange = cb; + } + + /** Колбэк изменения патронов оружия (для GUI). */ + setOnAmmoChange(cb) { + this._onAmmoChange = cb; + if (this.weapons) this.weapons.setOnAmmoChange(cb); + } + + /** Колбэк попадания пули (для логики урона зомби и др.). */ + setOnWeaponHit(cb) { + this._onWeaponHit = cb; + if (this.weapons) this.weapons.setOnHit(cb); + } + + /** Колбэк изменения HP игрока. */ + setOnPlayerHpChange(cb) { + this._onPlayerHpChange = cb; + if (this.player) this.player.setOnHpChange(cb); + } + + /** Колбэк смерти игрока. */ + setOnPlayerDeath(cb) { + this._onPlayerDeath = cb; + if (this.player) this.player.setOnDeath(cb); + } + + /** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */ + setOnEditorEscape(cb) { + this._onEditorEscape = cb; + } + + getInventoryState() { + return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 }; + } + + setActiveInventorySlot(index) { + this.inventory?.setActive(index); + // Если в Play — пересменяем оружие + if (this._isPlaying && this.weapons) { + const active = this.inventory?.getActive?.(); + if (active && active.kind === 'weapon') { + this.weapons.equip(active); + } else { + this.weapons.unequip(); + } + // Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты + // увидели в руке нашей модели правильный GLB. + if (this._mpSync) { + const modelId = (active && active.kind === 'weapon') + ? (active.modelTypeId || '') + : ''; + try { this._mpSync.sendWeapon(modelId); } catch (e) {} + } + } + } + + addInventoryItem(item) { + return this.inventory?.add(item) ?? -1; + } + + /** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */ + createGuiElement(type, opts) { + return this.guiManager?.create(type, opts); + } + updateGuiElement(id, patch) { + this.guiManager?.update(id, patch); + } + removeGuiElement(id) { + // Если был выделен — снять выделение + if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) { + this.selection.clearSelection?.(); + } + this.guiManager?.remove(id); + } + renameGuiElement(id, name) { + this.guiManager?.rename(id, name); + } + moveGuiElementZ(id, direction) { + this.guiManager?.moveZ(id, direction); + } + getGuiElements() { + return this.guiManager ? this.guiManager.getAll() : []; + } + + // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== + // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). + _ensureSkinShopState() { + if (!this._skinShop) { + this._skinShop = { + open: false, + rev: 0, // ревизия — React видит изменение + data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, + buyResult: null, // последний результат покупки {slug, ok, reason} + }; + } + return this._skinShop; + } + /** Снимок состояния магазина для React (поллинг через rAF). */ + getSkinShopState() { return this._skinShop || null; } + /** Открыть/закрыть магазин (из скрипта или клавиши B). */ + _openSkinShop() { + const s = this._ensureSkinShopState(); + // Отключён в проекте? (скрипт всё равно может открыть через API — + // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). + s.open = true; s.rev++; + } + _closeSkinShop() { + const s = this._ensureSkinShopState(); + s.open = false; s.rev++; + } + toggleSkinShop() { + const s = this._ensureSkinShopState(); + if (s.open) { this._closeSkinShop(); return; } + // Клавиша B открывает магазин только если он включён в проекте. + if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; + this._openSkinShop(); + } + /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ + _setSkinShopData(data) { + const s = this._ensureSkinShopState(); + s.data = { ...s.data, ...data }; + s.rev++; + } + _onSkinBuyResult(res) { + const s = this._ensureSkinShopState(); + s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; + s.rev++; + } + /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ + requestBuySkin(slug, price) { + const rt = this.gameRuntime; + if (!rt) return; + try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} + } + /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ + getAssetDataUrl(slug) { + try { + // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. + const list = this._skinsConfig?.customGlbs || []; + const rec = list.find(g => g && g.slug === slug); + if (rec && rec.dataUrl) return rec.dataUrl; + } catch (e) {} + return null; + } + _onPlayerSkinChanged(slug) { + const s = this._ensureSkinShopState(); + if (s.data) { s.data.current = slug; s.rev++; } + } + + // ===== Библиотека пользовательских картинок (этап 3.6) ===== + + /** Список картинок проекта [{id, name, dataUrl}]. */ + getAssets() { + return this.assetManager ? this.assetManager.list() : []; + } + + /** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */ + addAssetFromFile(file) { + if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' }); + return this.assetManager.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameAsset(id, name) { + if (!this.assetManager) return; + this.assetManager.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались — + * иначе остались бы «висячие» ссылки на несуществующий ассет. + */ + removeAsset(id) { + if (!this.assetManager) return; + this.assetManager.remove(id); + // Снять текстуру с примитивов, использовавших этот ассет. + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + if (data.textureAsset === id) { + this.primitiveManager.updateInstance(data.id, { textureAsset: null }); + } + } + } + // Снять картинку с GUI-элементов image. + if (this.guiManager) { + for (const el of this.guiManager.getAll()) { + if (el.imageAsset === id) { + this.guiManager.update(el.id, { imageAsset: null }); + } + } + } + if (this._onSceneChange) this._onSceneChange(); + } + + // ===== Библиотека пользовательских звуков (Фаза 5.5) ===== + + /** Список звуков проекта [{id, name, dataUrl}]. */ + getSounds() { + return this.soundLibrary ? this.soundLibrary.list() : []; + } + + /** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */ + addSoundFromFile(file) { + if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); + return this.soundLibrary.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameSound(id, name) { + if (!this.soundLibrary) return; + this.soundLibrary.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + removeSound(id) { + if (!this.soundLibrary) return; + this.soundLibrary.remove(id); + if (this._onSceneChange) this._onSceneChange(); + } + + // ===== Библиотека импортированных .glb-моделей (Фаза 5.8) ===== + + /** Список импортированных моделей [{id, name, dataUrl}]. */ + getGlbModels() { + return this.glbLibrary ? this.glbLibrary.list() : []; + } + + /** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */ + addGlbFromFile(file) { + if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); + return this.glbLibrary.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameGlb(id, name) { + if (!this.glbLibrary) return; + this.glbLibrary.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + removeGlb(id) { + if (!this.glbLibrary) return; + this.glbLibrary.remove(id); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Колбэк после постановки нового объекта (блока/модели/примитива). + * Используется KubikonEditor чтобы переключить activeTool на 'select' + * и дать пользователю сразу таскать поставленный объект. + */ + setOnPostPlace(cb) { + this._onPostPlace = cb; + } + + /** + * Сохранить позиции всех unanchored объектов перед стартом физики. + * При exitPlayMode они возвращаются на эти позиции. + */ + _snapshotDynamicObjects() { + this._dynamicSnapshot = []; + if (this.blockManager) { + // Запоминаем позиции unanchored блоков (mesh-position). + // Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная + // логика (сериализация, удаление, выделение) сломается. + // PhysicsAABB при Play фильтрует hasBlock через metadata.anchored + // и не считает unanchored клетку статичным препятствием. + for (const mesh of this.blockManager.blocks.values()) { + if (mesh.metadata?.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'block', + mesh, + x: mesh.position.x, y: mesh.position.y, z: mesh.position.z, + rotX: mesh.rotation?.x || 0, + rotY: mesh.rotation?.y || 0, + rotZ: mesh.rotation?.z || 0, + }); + } + } + } + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + if (data.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'primitive', data, + x: data.x, y: data.y, z: data.z, + rotX: data.mesh?.rotation?.x || 0, + rotY: data.mesh?.rotation?.y || 0, + rotZ: data.mesh?.rotation?.z || 0, + }); + } + } + } + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + if (data.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'model', data, + x: data.x, y: data.y, z: data.z, + rotX: data.rootMesh?.rotation?.x || 0, + rotY: data.rootMesh?.rotation?.y || 0, + rotZ: data.rootMesh?.rotation?.z || 0, + }); + } + } + } + } + + _restoreDynamicObjects() { + if (!this._dynamicSnapshot) return; + for (const snap of this._dynamicSnapshot) { + if (snap.kind === 'block' && snap.mesh) { + snap.mesh.position.x = snap.x; + snap.mesh.position.y = snap.y; + snap.mesh.position.z = snap.z; + if (snap.mesh.rotation) { + snap.mesh.rotation.x = snap.rotX || 0; + snap.mesh.rotation.y = snap.rotY || 0; + snap.mesh.rotation.z = snap.rotZ || 0; + } + snap.mesh.setEnabled(true); + } else if (snap.kind === 'primitive' && snap.data) { + snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; + if (snap.data.mesh) { + snap.data.mesh.position.set(snap.x, snap.y, snap.z); + if (snap.data.mesh.rotation) { + snap.data.mesh.rotation.x = snap.rotX || 0; + snap.data.mesh.rotation.y = snap.rotY || 0; + snap.data.mesh.rotation.z = snap.rotZ || 0; + } + snap.data.mesh.setEnabled(snap.data.visible !== false); + } + } else if (snap.kind === 'model' && snap.data) { + snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; + if (snap.data.rootMesh) { + snap.data.rootMesh.position.set(snap.x, snap.y, snap.z); + if (snap.data.rootMesh.rotation) { + snap.data.rootMesh.rotation.x = snap.rotX || 0; + snap.data.rootMesh.rotation.y = snap.rotY || 0; + snap.data.rootMesh.rotation.z = snap.rotZ || 0; + } + snap.data.rootMesh.setEnabled(true); + } + } + } + this._dynamicSnapshot = null; + } + + /** + * Полный снимок сцены перед Play — примитивы и модели целиком. + * При exitPlayMode сцена восстанавливается ровно к этому состоянию: + * вернутся удалённые скриптом объекты, откатятся цвет/видимость/ + * коллизия/поворот, исчезнут заспавненные скриптом объекты. + * + * Зачем: скрипты игр меняют сцену деструктивно (game.self.delete, + * setColor, tween rotationY и т.д.). Без полного отката после + * Stop→Play сцена остаётся «использованной» — собранные монетки + * не появляются, открытая дверь остаётся открытой. Это как Stop + * в Roblox Studio: сцена возвращается к авторскому виду. + * + * Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная + * пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так + * откатываются через _restoreDynamicObjects (позиции). + */ + _snapshotFullScene() { + this._fullSceneSnapshot = null; + try { + const snap = {}; + if (this.primitiveManager) { + snap.primitives = this.primitiveManager.serialize(); + } + if (this.modelManager) { + snap.models = this.modelManager.serialize(); + } + this._fullSceneSnapshot = snap; + } catch (e) { + console.warn('[BabylonScene] _snapshotFullScene не удался:', e); + this._fullSceneSnapshot = null; + } + } + + /** + * Восстановить сцену из полного снимка после Play. + * Пересоздаёт примитивы и модели точь-в-точь (id сохраняются — + * addInstance принимает opts.id, поэтому скрипты на объектах после + * рестарта снова найдут свои target.id). + */ + _restoreFullScene() { + if (!this._fullSceneSnapshot) return; + const snap = this._fullSceneSnapshot; + this._fullSceneSnapshot = null; + try { + // Сбрасываем выделение — loadFromArray диспозит старые mesh, + // selection не должен держать мёртвую ссылку. + try { this.selection?.clear?.(); } catch (e) {} + if (this.primitiveManager && Array.isArray(snap.primitives)) { + this.primitiveManager.loadFromArray(snap.primitives); + } + if (this.modelManager && Array.isArray(snap.models)) { + // loadFromArray у моделей async (модели грузятся с диска) — + // не ждём, восстановление догрузится в фоне. + Promise.resolve(this.modelManager.loadFromArray(snap.models)) + .catch((e) => console.warn('[BabylonScene] откат моделей:', e)); + } + } catch (e) { + console.warn('[BabylonScene] _restoreFullScene не удался:', e); + } + } + + /** Поменять anchored у выделенного объекта. */ + setSelectedAnchored(anchored) { + this.selection?.setSelectedAnchored(anchored); + } + + /** === Окружение / Время суток / Аудио / Вода === */ + setEnvironmentPreset(preset) { this.environment?.setPreset(preset); } + setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); } + setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); } + setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); } + getEnvironmentState() { return this.environment?.serialize() || null; } + + setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); } + setMusicAudio(opts) { this.audioManager?.setMusic(opts); } + getAudioState() { return this.audioManager?.serialize() || null; } + + /** Доступные пресеты амбиента/музыки для UI. */ + getAudioPresets() { + return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] }; + } + + /** Доступные модели игрока (категория «Персонажи»). */ + getPlayerOptions() { + // Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache + return this._playerOptionsCache || []; + } + setPlayerOptions(list) { this._playerOptionsCache = list; } + + /** Обновить пресет амбиента/музыки и обновить selection если открыт. */ + setSoundProps(patch) { + if (!patch) return; + if (patch.ambientId !== undefined) { + this.audioManager?.setAmbient({ preset: patch.ambientId }); + } + if (patch.musicId !== undefined) { + this.audioManager?.setMusic({ preset: patch.musicId }); + } + if (this.selection?._selection?.type === 'sound') { + this.selection.selectSound(); + } + } + + /** Обновить тип персонажа / силу прыжка / прицел. */ + setPlayerProps(patch) { + if (!patch) return; + if (patch.playerModelType) { + this.setPlayerModelType(patch.playerModelType); + } + if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) { + this.setPlayerJumpPower(patch.jumpPower); + } + if (typeof patch.crosshair === 'string') { + this.setCrosshair(patch.crosshair); + } + if (this.selection?._selection?.type === 'player') { + this.selection.selectPlayer(); + } + } + + /** Поменять mass у выделенного объекта. */ + setSelectedMass(mass) { + this.selection?.setSelectedMass(mass); + } + + /** Поменять свойства модели (canCollide / visible). */ + setSelectedModelProps(patch) { + if (!this.selection) return; + const sel = this.selection.getSelection(); + if (sel?.type === 'userModel') { + this.selection.setSelectedUserModelProps(patch); + return; + } + this.selection.setSelectedModelProps(patch); + } + + /** Поменять свойства блока (canCollide / visible). */ + setSelectedBlockProps(patch) { + this.selection?.setSelectedBlockProps(patch); + } + + /** === Папки/группы === */ + createFolder(name = 'Новая папка', parentId = null) { + return this.folderManager?.createFolder(name, parentId) ?? null; + } + renameFolder(id, name) { this.folderManager?.renameFolder(id, name); } + + /** Переименовать скрипт по id. Имя сохраняется в поле name. */ + renameScript(id, name) { + const i = this._scripts.findIndex(s => s.id === id); + if (i < 0) return false; + this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null }; + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + return true; + } + + /** Переименовать инстанс модели. */ + renameModel(instanceId, name) { + const data = this.modelManager?.instances?.get(instanceId); + if (!data) return false; + data.name = String(name || '').trim() || null; + if (this._onSceneChange) this._onSceneChange(); + this.modelManager?._notifyChange?.(); + return true; + } + + /** Переименовать примитив. */ + renamePrimitive(id, name) { + const data = this.primitiveManager?.instances?.get(id); + if (!data) return false; + data.name = String(name || '').trim() || null; + if (this._onSceneChange) this._onSceneChange(); + this.primitiveManager?._notifyChange?.(); + return true; + } + removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); } + setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); } + assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); } + /** Положить выделенное в указанную папку (или null = в корень). */ + assignSelectionToFolder(folderId) { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId); + } else if (sel.type === 'model') { + this.folderManager?.assignToFolder('model', sel.instanceId, folderId); + } else if (sel.type === 'primitive') { + this.folderManager?.assignToFolder('primitive', sel.id, folderId); + } + } + + /** Undo. */ + undo() { + return this.history?.undo(); + } + + /** Redo. */ + redo() { + return this.history?.redo(); + } + + /** Можно ли откатиться. */ + canUndo() { + return !!this.history?.canUndo(); + } + + /** Можно ли вернуть. */ + canRedo() { + return !!this.history?.canRedo(); + } + + /** + * Захватить превью-скриншот сцены как data URL (PNG, base64). + * Используется для иконки проекта в «Мои игры». + * size — размер квадратного превью в пикселях (по умолчанию 256). + */ + captureThumbnail(size = 256) { + if (!this.canvas) return null; + try { + // Простейший способ — взять текущий canvas-buffer и масштабировать его + // в новый offscreen canvas размера size×size. + const out = document.createElement('canvas'); + out.width = size; + out.height = size; + const ctx = out.getContext('2d'); + // Чёрная заливка на случай прозрачности + ctx.fillStyle = '#1a1410'; + ctx.fillRect(0, 0, size, size); + // Принудительный рендер чтобы backbuffer был свежим + if (this.scene) this.scene.render(); + // Сохраняем сохраняя пропорции — рисуем по короткой стороне + const sw = this.canvas.width, sh = this.canvas.height; + const minSide = Math.min(sw, sh); + const sx = (sw - minSide) / 2; + const sy = (sh - minSide) / 2; + ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size); + return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ + } catch (e) { + // eslint-disable-next-line no-console + console.error('[BabylonScene] thumbnail error:', e); + return null; + } + } + + // === СОХРАНЕНИЕ И ЗАГРУЗКА =========================================== + + /** + * Сериализовать сцену в JSON-объект для сохранения в БД. + * Включает: блоки, модели, точку спавна, позицию редактор-камеры. + */ + /** + * Подготовить мини-карту для уже загруженного проекта (когда нет + * GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в + * window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно. + * MinimapOverlay должна сама уметь рендерить real-data fallback. + */ + _setupMinimapForLoadedProject() { + if (!this.terrainManager || !this.terrainManager.voxels) return; + const voxels = this.terrainManager.voxels; + if (voxels.size === 0) return; + // Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize. + let maxAbs = 0; + for (const key of voxels.keys()) { + const lastComma = key.lastIndexOf(','); + const midComma = key.lastIndexOf(',', lastComma - 1); + const x = parseInt(key.slice(0, midComma), 10); + const z = parseInt(key.slice(lastComma + 1), 10); + const ax = Math.abs(x); + const az = Math.abs(z); + if (ax > maxAbs) maxAbs = ax; + if (az > maxAbs) maxAbs = az; + } + // maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас). + const size = Math.ceil(maxAbs * 1.1); + window.__lastGenSize = size; + // Если ничего не было сгенерировано — НЕ ставим __lastGenParams. + // MinimapOverlay использует fallback на real-data top-down рендер. + console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`); + } + + /** + * Подготовить мини-карту для гладкого ландшафта (RobloxTerrain). + * + * Воксельная миникарта (_setupMinimapForLoadedProject) читает + * terrainManager.voxels — для smooth terrain их нет. Здесь публикуем + * ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay + * сам строит top-down heightmap из неё. + * + * Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible + * (он показывается по этому флагу), даже если у гладкого ландшафта + * нет настоящего streaming-режима. + */ + _setupMinimapForRobloxTerrain() { + const grid = this._robloxTerrain?.grid; + if (!grid) return; + // CELL_SIZE=4м. Полная ширина карты в метрах. + const worldM = grid.size.x * 4; + // MinimapOverlay масштаб: getWorldViewM() = size*2*0.25. + // Чтобы worldViewM == worldM → size = worldM*2. + window.__lastGenSize = worldM * 2; + window.__lastGenParams = null; // не procedural-режим + window.__robloxMinimapGrid = grid; // density-grid для real-data рендера + this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым + // ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это + // зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались + // к центру и проваливались). Подгоняем под реальный размер карты. + const half = Math.ceil(worldM / 2); + if (this._worldHalf < half) { + this._worldHalf = half; + // Синхронизируем physics.floorHalf — иначе игрок проваливается + // сквозь baseplate за пределами центральных 80×80. + if (this.physics) this.physics.floorHalf = half; + console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`); + } + console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`); + } + + /** + * Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain). + * + * Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит + * сглаженную поверхность между ячейками — реальная видимая высота + * отличается от грубой оценки по grid. Чтобы корректно ставить + * объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша. + * + * Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек + * (шаг step метров) и возвращает объект с картой высот. Используется + * билд-скриптами игр для точного размещения. + * + * @param {number} step шаг сетки в метрах (по умолчанию 2) + * @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] } + * heights — плоский массив (rows × cols), значение = Y поверхности или null. + */ + exportRobloxHeightmap(step = 2) { + const rt = this._robloxTerrain; + if (!rt || !rt.grid) { + console.warn('[exportRobloxHeightmap] нет гладкого ландшафта'); + return null; + } + const grid = rt.grid; + const CS = 4; // CELL_SIZE + // Мировые границы карты по grid. + const minWX = grid.origin.x * CS; + const minWZ = grid.origin.z * CS; + const worldX = grid.size.x * CS; + const worldZ = grid.size.z * CS; + const cols = Math.ceil(worldX / step) + 1; + const rows = Math.ceil(worldZ / step) + 1; + + // Предикат: только меши гладкого ландшафта. + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + const heights = new Array(cols * rows).fill(null); + let hit = 0; + const t0 = performance.now(); + for (let r = 0; r < rows; r++) { + const wz = minWZ + r * step; + for (let c = 0; c < cols; c++) { + const wx = minWX + c * step; + const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000); + const pick = this.scene.pickWithRay(ray, pickPred); + if (pick && pick.hit && pick.pickedPoint) { + heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3); + hit++; + } + } + } + const dt = (performance.now() - t0).toFixed(0); + console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`); + + return { + format: 'roblox-heightmap-v1', + origin: { x: minWX, z: minWZ }, + worldSize: { x: worldX, z: worldZ }, + gridOrigin: { ...grid.origin }, + gridSize: { ...grid.size }, + step, + cols, + rows, + heights, + }; + } + + serialize() { + // Принадлежность объектов папкам — серилизуется в их собственных + // данных (folderId), а сами папки в отдельном массиве. + const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; + // BlockManager.serialize не знает про folderId — добавляем его поверх. + if (this.blockManager) { + for (const item of blocksWithFolders) { + const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); + item.folderId = mesh?.metadata?.folderId ?? null; + } + } + const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; + if (this.modelManager) { + // Дописываем instanceId + folderId поверх стандартной сериализации + // (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass) + const live = Array.from(this.modelManager.instances.values()); + for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) { + modelsWithFolders[i].instanceId = live[i].instanceId; + modelsWithFolders[i].folderId = live[i].folderId ?? null; + } + } + const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : []; + if (this.primitiveManager) { + for (let i = 0; i < primitivesWithFolders.length; i++) { + const id = primitivesWithFolders[i].id; + const data = this.primitiveManager.instances.get(id); + primitivesWithFolders[i].folderId = data?.folderId ?? null; + } + } + // Terrain: RLE-формат для больших карт (×25 меньше чем legacy array). + // На карте 250м: ~1.5МБ вместо ~38МБ. + let terrainData; + if (this.terrainManager) { + const voxelCount = this.terrainManager.voxels?.size ?? 0; + if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') { + terrainData = this.terrainManager.serializeRLE(); + } else { + terrainData = this.terrainManager.serialize(); + } + } else { + terrainData = []; + } + // Roblox smooth terrain — отдельная подсистема, сериализуется параллельно + let robloxTerrainData = null; + if (this._robloxTerrain && this._robloxTerrain.grid) { + try { + robloxTerrainData = this._robloxTerrain.serialize(); + // Декорации сохраняем двумя способами: + // - decoParams (seed + density) — для процедурной генерации + // - decoInstances (полные матрицы) — для ручных правок plant-кистью + // При load — приоритет у decoInstances если они есть. + if (this._smoothDecoParams) { + robloxTerrainData.decoParams = this._smoothDecoParams; + } + if (this._smoothDecoManager) { + const stats = this._smoothDecoManager.getStats?.(); + if (stats && stats.total > 0) { + try { + robloxTerrainData.decoInstances = this._smoothDecoManager.serialize(); + } catch (e) { + console.warn('smoothDeco serialize failed:', e); + } + } + } + } catch (e) { console.warn('robloxTerrain serialize failed:', e); } + } + + return { + version: 1, + scene: { + blocks: blocksWithFolders, + models: modelsWithFolders, + primitives: primitivesWithFolders, + // Этап 5: пользовательские воксельные модели (созданные через + // ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }. + userModels: this.userModelManager ? this.userModelManager.serialize() : [], + terrain: terrainData, + robloxTerrain: robloxTerrainData, + decorations: this.decoManager ? this.decoManager.serialize() : [], + folders: this.folderManager ? this.folderManager.serialize() : [], + gui: this.guiManager ? this.guiManager.serialize() : [], + inventory: this.inventory ? this.inventory.serialize() : null, + spawnPoint: { ...this._spawnPoint }, + playerModelType: this._playerModelType, + skins: this._skinsConfig ? { + default: this._skinsConfig.default || null, + unlocked: this._skinsConfig.unlocked || [], + shopVisible: this._skinsConfig.shopVisible !== false, + coins: this._skinsConfig.coins || 0, + customGlbs: this._skinsConfig.customGlbs || [], + } : undefined, + worldSize: this._worldHalf * 2, + floorEnabled: this._floorEnabled !== false, + jumpPowerMul: this._jumpPowerMul ?? 1, + cameraMode: this._defaultCameraMode || 'third', + crosshair: this._crosshair || 'dot', + shadowQuality: this._shadowQuality || 'soft', + environment: this.environment ? this.environment.serialize() : null, + audio: this.audioManager ? this.audioManager.serialize() : null, + // Библиотека пользовательских картинок (текстуры/GUI-image). + assets: this.assetManager ? this.assetManager.serialize() : [], + // Библиотека пользовательских звуков (Фаза 5.5). + sounds: this.soundLibrary ? this.soundLibrary.serialize() : [], + // Импортированные .glb-модели (Фаза 5.8). + glbModels: this.glbLibrary ? this.glbLibrary.serialize() : [], + // ЭТАП 2.1: пропускаем demo-скрипт (он добавляется автоматически + // при загрузке если у проекта нет своих скриптов). + scripts: this._scripts + .filter(s => s.id !== 'demo') + .map(s => ({ + id: s.id, + code: s.code, + target: s.target || null, + name: s.name || null, + })), + }, + editorCamera: this.camera ? { + position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }, + rotation: { x: this.camera.rotation.x, y: this.camera.rotation.y, z: this.camera.rotation.z }, + } : null, + settings: { + // GD-проект (Geometry Dash) — включает GD-визуал в Play. + // Раньше определялось по диапазону id (296-395), но диапазон + // зарезервирован с запасом — обычные проекты туда попадали. + // Теперь — явный флаг в данных проекта. + isGd: this._isGdProject === true, + }, + }; + } + + /** + * Восстановить сцену из ранее сохранённого state. + * Очищает существующие блоки/модели, создаёт заново. + * Возвращает promise (async из-за загрузки моделей). + */ + async loadFromState(state) { + if (!state || !state.scene) return; + + // Флаг GD-проекта — из settings. Если в данных проекта флага нет + // (старые проекты до введения флага) — _isGdProject останется + // undefined, и enterPlayMode сделает fallback на диапазон id. + if (state.settings && typeof state.settings.isGd === 'boolean') { + this._isGdProject = state.settings.isGd; + } + + // Библиотека пользовательских картинок — грузим РАНЬШЕ примитивов, + // чтобы при создании примитива с textureAsset текстура уже была доступна. + if (this.assetManager) { + this.assetManager.load(Array.isArray(state.scene.assets) ? state.scene.assets : []); + } + // Библиотека пользовательских звуков (Фаза 5.5). + if (this.soundLibrary) { + this.soundLibrary.load(Array.isArray(state.scene.sounds) ? state.scene.sounds : []); + } + // Импортированные .glb-модели (Фаза 5.8) — грузим РАНЬШЕ моделей, + // чтобы при addInstance('glb:N') библиотека была готова. + if (this.glbLibrary) { + this.glbLibrary.load(Array.isArray(state.scene.glbModels) ? state.scene.glbModels : []); + } + + // Размер пола — пересоздаём пол если у проекта он другой + if (typeof state.scene.worldSize === 'number' && state.scene.worldSize > 0) { + this.setWorldSize(state.scene.worldSize); + } + if (typeof state.scene.floorEnabled === 'boolean') { + this.setFloorEnabled(state.scene.floorEnabled); + } + if (typeof state.scene.jumpPowerMul === 'number' && state.scene.jumpPowerMul > 0) { + this.setPlayerJumpPower(state.scene.jumpPowerMul); + } + if (typeof state.scene.crosshair === 'string') { + this.setCrosshair(state.scene.crosshair); + } + // Камера по умолчанию ('first' / 'third' / 'front'). Применяется при enterPlayMode. + if (typeof state.scene.cameraMode === 'string') { + this._defaultCameraMode = state.scene.cameraMode; + } + // Качество теней + if (state.scene.shadowQuality) { + this.setShadowQuality(state.scene.shadowQuality); + } + + // Блоки — синхронно + if (this.blockManager && Array.isArray(state.scene.blocks)) { + this.blockManager.loadFromArray(state.scene.blocks); + } + + // Террейн (voxel-ландшафт). Поддерживаем 2 формата: + // 1. Legacy: terrain = [{x,y,z,m}, ...] — старые проекты + // 2. RLE-v1: terrain = {format:'rle-v1', palette, chunks:{base64}} + // — новый формат для больших карт (×25 меньше) + const ts = state.scene.terrain; + // Прогресс-индикатор: глобальный объект, KubikonEditor.jsx читает в polling + const setProgress = (percent, label) => { + if (typeof window !== 'undefined') { + window.__kubikonLoadProgress = { percent, label, ts: performance.now() }; + } + }; + setProgress(2, 'Подготовка сцены…'); + if (this.terrainManager && ts) { + const tLoad0 = performance.now(); + if (Array.isArray(ts)) { + // Legacy формат + const tCount = ts.length; + console.log(`[BabylonScene] LOAD terrain (legacy): ${tCount} voxels`); + await this.terrainManager.loadFromArray(ts, (loaded, total) => { + if (total > 5000 && loaded % 10000 < 2001) { + console.log(`[BabylonScene] terrain: ${loaded}/${total}`); + // 5-40% — заливка вокселей + setProgress(5 + Math.floor((loaded / total) * 35), `Размещение блоков: ${loaded.toLocaleString()} / ${total.toLocaleString()}`); + } + }); + } else if (ts.format === 'rle-v1' && typeof this.terrainManager.loadFromRLE === 'function') { + // RLE формат + const chunkCount = Object.keys(ts.chunks || {}).length; + console.log(`[BabylonScene] LOAD terrain (RLE): ${chunkCount} chunks, palette=${(ts.palette || []).length - 1}`); + await this.terrainManager.loadFromRLE(ts, (loaded, total) => { + if (loaded % 16 === 0) { + console.log(`[BabylonScene] terrain RLE: chunk ${loaded}/${total}`); + // 5-40% — распаковка RLE + setProgress(5 + Math.floor((loaded / total) * 35), `Распаковка карты: ${loaded} / ${total} чанков`); + } + }); + } else { + console.warn('[BabylonScene] unknown terrain format:', ts); + } + setProgress(75, 'Сборка геометрии регионов…'); + const tLoad1 = performance.now(); + const finalVoxelCount = this.terrainManager.voxels?.size ?? 0; + const regionCount = this.terrainManager.getRegionCount?.() ?? 0; + console.log(`[BabylonScene] LOAD done in ${(tLoad1 - tLoad0).toFixed(0)}ms: ${finalVoxelCount} voxels → ${regionCount} regions`); + + // Shadow-load в VoxelWorld — ТОЛЬКО для маленьких карт. + // На больших (>30K voxels) это лишняя нагрузка (память + время). + // Если когда-то перейдём на новый рендер — сами решим shadow-load заново. + if (Array.isArray(ts) && finalVoxelCount > 0 && finalVoxelCount < 30000) { + try { + this.voxelWorld.loadLegacyTerrain(ts); + const s = this.voxelWorld.stats(); + console.log(`[VoxelWorld] shadow-loaded: ${s.totalVoxels} voxels in ${s.totalChunks} chunks`); + } catch (e) { + console.warn('[VoxelWorld] shadow-load failed:', e); + } + } else if (finalVoxelCount >= 30000) { + console.log(`[VoxelWorld] shadow-load SKIPPED (${finalVoxelCount} voxels > 30000 — экономим память)`); + } + + // === АВТО-STREAMING для загруженных больших проектов === + if (regionCount > 0) { + this._terrainStreamingEnabled = true; + // Адаптивный radius: чем больше карта, тем меньше radius. + // Поднял пороги (2026-05-27): на средних картах (1-3M voxels) + // streaming-radius 28-32м слишком мал для замкнутых объектов + // типа вулкана — игрок видит «полупрозрачные» стены, потому + // что дальние регионы стенки не материализованы. + let radius = 80; + if (finalVoxelCount > 5_000_000) radius = 40; + else if (finalVoxelCount > 3_000_000) radius = 55; + else if (finalVoxelCount > 1_000_000) radius = 70; + this._terrainStreamingRadius = radius; + this._terrainStreamingLastUpdate = 0; + // Автотуман для скрытия границы streaming. Без него видно + // резкий обрыв террейна на радиусе. + // + // Density подбираем по editor-radius: чем больше radius, + // тем дальше начало тумана. Для radius=72м: 0.005 — туман + // едва заметен в ближнем плане, но прячет обрыв на 70м. + try { + if (this.scene) { + const camY = this.camera?.position.y || 0; + const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); + const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); + // Эмпирически: fogDensity ≈ 0.5/radius работает. + // На radius=72м → 0.007 (туман на 70-90м) + // На radius=40м → 0.0125 (туман на 40-60м, как раньше) + const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius)); + this.scene.fogMode = 2; // FOGMODE_EXP + this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой + this.scene.fogDensity = density; + } + } catch (e) {} + // Сразу первый pass с editor-radius формулой (см. render loop). + if (this.camera) { + const camY = this.camera.position.y || 0; + const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); + const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); + const r = this.terrainManager.updateStreaming( + this.camera.position.x, this.camera.position.z, + editorRadius, + ); + console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`); + } + this._setupMinimapForLoadedProject(); + } else { + console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`); + } + } + + // === Загрузка Roblox smooth terrain (параллельная подсистема) === + // Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid. + const rts = state.scene.robloxTerrain; + if (rts && rts.format === 'robloxterrain-v1') { + try { + setProgress(90, 'Загрузка гладкого ландшафта…'); + if (!this._robloxTerrain) { + this._robloxTerrain = new RobloxTerrain(this.scene); + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + } + this._robloxTerrain.loadFromState(rts); + // Сразу материализуем chunks вокруг камеры + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); + const stats = this._robloxTerrain.getStats(); + console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`); + // Включаем мини-карту для гладкого ландшафта — MinimapOverlay + // показывается по флагу _terrainStreamingEnabled, а heightmap + // строит из density-grid (window.__robloxMinimapGrid). + this._setupMinimapForRobloxTerrain(); + // Если есть RobloxTerrain — отключаем baseplate-floor чтобы + // не создавал ложных коллизий под smooth-ландшафтом. + // НО только если рельеф большой (≥500 cells) — иначе baseplate + // нужен для визуального ориентирования и для plant-decos + // которые ставятся на y=0. + if (stats.solidCells >= 500) { + try { this.setFloorEnabled(false); } catch (e) {} + } + // === Загрузка smooth-decorations === + // Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью) + // Приоритет 2: decoParams (seed-based процедурная генерация) + if (rts.decoInstances && rts.decoInstances.items?.length > 0) { + try { + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + const r = await this._smoothDecoManager.loadFromState(rts.decoInstances); + const total = r?.total ?? 0; + const tc = r?.treeColliders ?? []; + console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`); + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(tc); + } + } catch (err) { + console.error('[BabylonScene] smooth decoInstances load failed:', err); + } + } else if (rts.decoParams) { + try { + const dp = rts.decoParams; + this._smoothDecoParams = dp; + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + await this._smoothDecoManager.loadAll(); + const decoGen = new WorldGenerator(dp.genParams); + const sampleSurfaceY = (x, z) => { + if (!this.physics?._sampleRobloxSurface) return null; + return this.physics._sampleRobloxSurface(x, z); + }; + const sampleBiomeId = (x, z) => { + const b = decoGen.sampleBiome(x * 4, z * 4); + return b?.id; + }; + const r2 = this._smoothDecoManager.placeDecorations({ + sampleSurfaceY, sampleBiomeId, bbox: dp.bbox, + densityFlowers: dp.flowersDensity, + densityGrass: dp.grassDensity, + densityTrees: dp.treesDensity ?? 0, + seed: dp.seed, + }); + console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`); + if (this.physics?.setSmoothDecoTrees && r2.treeColliders) { + this.physics.setSmoothDecoTrees(r2.treeColliders); + } + } catch (err) { + console.error('[BabylonScene] smooth decorations load failed:', err); + } + } + } catch (err) { + console.error('[BabylonScene] robloxTerrain load failed:', err); + } + } + + setProgress(95, 'Размещение декораций…'); + // Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями). + if (this.decoManager && Array.isArray(state.scene.decorations)) { + this.decoManager.loadFromArray(state.scene.decorations); + // Этап D LOD: первый pass streaming для деко. + // maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется + // мгновенно). Остальные доступные подгружаются по 2-4 за тик + // (200мс), весь видимый набор за 1-2 секунды. UI не блокируется. + if (this.camera && this.decoManager.updateStreaming) { + const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); + const r = this.decoManager.updateStreaming( + this.camera.position.x, this.camera.position.z, decoRadius, + { maxBuild: 2 }, + ); + console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`); + } + } + + // Модели — асинхронно (GLB подгружаются) + if (this.modelManager && Array.isArray(state.scene.models)) { + await this.modelManager.loadFromArray(state.scene.models); + } + + // Этап 5: пользовательские воксельные модели — асинхронно + // (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}. + if (this.userModelManager && Array.isArray(state.scene.userModels) + && state.scene.userModels.length > 0) { + const loaded = await this.userModelManager.loadFromArray( + state.scene.userModels, + { currentUserId: this._currentUserId }, + ); + console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`); + // Регистрируем коллайдеры в физике + 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; + } + // Задача 12: конфиг экрана загрузки. + if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') { + const ls = state.scene.loadingScreen; + this._loadingConfig = { + logo: ls.logo || null, + accentColor: ls.accentColor || '#ffc020', + defaultSpinner: ls.defaultSpinner !== false, + defaultSkipButton: !!ls.defaultSkipButton, + }; + } else { + this._loadingConfig = null; + } + // Задача 13: конфиг главного меню (passthrough). + this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object') + ? state.scene.mainMenu : null; + + // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — + // PlayerController.start() её ждёт, но если предзагрузить сейчас, + // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. + // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters// + // body.glb + манифест), ModelManager их не знает. Их грузит сам + // PlayerController через _loadSkinManifest — здесь пропускаем, + // иначе ModelManager пишет в консоль 'Unknown model type'. + try { + const playerModelType = this._playerModelType || 'character-a'; + if (!String(playerModelType).startsWith('skin_')) { + await this.modelManager?._loadPrototype?.(playerModelType); + } + } catch (e) { /* ignore */ } + + // Примитивы — синхронно + if (this.primitiveManager && Array.isArray(state.scene.primitives)) { + this.primitiveManager.loadFromArray(state.scene.primitives); + } + + // Папки + восстановление folderId на всех объектах + if (this.folderManager) { + this.folderManager.loadFromArray(state.scene.folders || []); + } + // GUI-элементы + if (this.guiManager) { + this.guiManager.loadFromArray(state.scene.gui || []); + } + // Инвентарь + if (this.inventory) { + this.inventory.loadFromArray(state.scene.inventory || null); + } + // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) + if (this.blockManager && Array.isArray(state.scene.blocks)) { + for (const b of state.scene.blocks) { + if (b.folderId == null) continue; + const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`); + if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId; + } + } + if (this.modelManager && Array.isArray(state.scene.models)) { + // ModelManager.loadFromArray генерирует новые instanceId, + // поэтому folderId восстанавливаем по индексу (порядку). + const arr = state.scene.models; + const liveIds = Array.from(this.modelManager.instances.keys()); + for (let i = 0; i < arr.length && i < liveIds.length; i++) { + if (arr[i].folderId == null) continue; + const data = this.modelManager.instances.get(liveIds[i]); + if (data) data.folderId = arr[i].folderId; + } + } + if (this.primitiveManager && Array.isArray(state.scene.primitives)) { + // primitiveManager после loadFromArray генерирует новые id, поэтому + // восстановим folderId по индексу (порядку) — он совпадает. + const arr = state.scene.primitives; + const liveIds = Array.from(this.primitiveManager.instances.keys()); + for (let i = 0; i < arr.length && i < liveIds.length; i++) { + if (arr[i].folderId == null) continue; + const data = this.primitiveManager.instances.get(liveIds[i]); + if (data) data.folderId = arr[i].folderId; + } + } + // После расстановки folderId — применим эффективную видимость папок + if (this.folderManager) { + for (const f of this.folderManager.getAll()) { + this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id)); + } + } + + // Зарегистрировать все объекты как shadow casters + this.refreshAllShadows(); + + // Точка спавна + if (state.scene.spawnPoint) { + this._spawnPoint = { ...state.scene.spawnPoint }; + this._updateSpawnMarker(); + } + // === Авто-fix спавна для smooth terrain === + // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — + // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". + // Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh + // обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает + // в бездну. + try { + if (this._robloxTerrain + && (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0 + && this.physics?._sampleRobloxSurface) { + const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z); + if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) { + const newY = surfaceY + 2; + console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`); + this._spawnPoint.y = newY; + this._updateSpawnMarker(); + } else if (surfaceY === null) { + console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn'); + } + } + } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } + // (Тип модели персонажа и skins решены выше — до предзагрузки модели.) + // Пользовательские скрипты + if (Array.isArray(state.scene.scripts)) { + this._scripts = state.scene.scripts + .filter(s => s && typeof s.code === 'string') + .map(s => ({ + id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + code: s.code, + target: s.target || null, + name: s.name || null, + })); + } + // Окружение (время суток, скайбокс, туман) + if (state.scene.environment && this.environment) { + this.environment.load(state.scene.environment); + } + // Аудио (фоновая музыка/амбиент) + if (state.scene.audio && this.audioManager) { + this.audioManager.load(state.scene.audio); + } + + // Редактор-камера + if (state.editorCamera && this.camera) { + const c = state.editorCamera; + if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z); + if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z); + } + // Финальный прогресс — UI скроет overlay + if (typeof window !== 'undefined') { + window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() }; + } + } + + /** + * Задача 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() { + if (!this._isPlaying) return; + this._isPlaying = false; + // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе + try { this.modalManager?._instantClose?.(); } catch (e) {} + // Сбрасываем таймер прохождения + this._timerRunning = false; + this._timerStartedAt = null; + // Отключаем picking voxel-террейна обратно (нужно только в play). + try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {} + // Размораживаем world-matrix статичных моделей — в редакторе + // пользователь может двигать их через гизмо. + try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {} + try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {} + // Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается) + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + const m = data.mesh; + if (m && m._kubikonPrimCulled === true) { + m.setEnabled(data.visible !== false); + m._kubikonPrimCulled = false; + } + } + } + + // Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а, + // чтобы скрипты не успели потрогать player в момент disposal. + if (this.gameRuntime) { + this.gameRuntime.stop(); + this.gameRuntime = null; + } + + // Placement mode (задача 11): сброс активной сессии + виджета магазина. + if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} } + if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; } + if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; } + if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; } + if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; } + + // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) + if (this.gdLevelManager) { + this.gdLevelManager.stop(); + } + // Этап G1: убрать skybox/параллакс (откатывает fog/clearColor) + if (this.gdSkybox) { + try { this.gdSkybox.dispose(); } catch (e) {} + this.gdSkybox = null; + } + // Этап G2: убрать декоративную траву + neon-edge + if (this.gdGroundSkin) { + try { this.gdGroundSkin.dispose(); } catch (e) {} + this.gdGroundSkin = null; + } + // Этап G3: убрать кастомные шипы, вернуть оригинальные конусы + if (this.gdSpikes) { + try { this.gdSpikes.dispose(); } catch (e) {} + this.gdSpikes = null; + } + // Этап G4: убрать стартовую арку + if (this.gdStartArch) { + try { this.gdStartArch.dispose(); } catch (e) {} + this.gdStartArch = null; + } + // Этап G5: убрать финиш-ворота + if (this.gdFinish) { + try { this.gdFinish.dispose(); } catch (e) {} + this.gdFinish = null; + } + // Этап G6: убрать деревья/кусты + if (this.gdForest) { + try { this.gdForest.dispose(); } catch (e) {} + this.gdForest = null; + } + // Этап G7: снять эффекты с куба игрока + if (this.gdPlayerCube) { + try { this.gdPlayerCube.dispose(); } catch (e) {} + this.gdPlayerCube = null; + } + // Этап G8: trail-частицы + if (this.gdPlayerTrail) { + try { this.gdPlayerTrail.dispose(); } catch (e) {} + this.gdPlayerTrail = null; + } + // Этап G9: пост-обработка (bloom/vignette/освещение) + if (this.gdPostFx) { + try { this.gdPostFx.dispose(); } catch (e) {} + this.gdPostFx = null; + } + + // Выключаем оружие + if (this.weapons) { + this.weapons.stop(); + } + // Выключаем зомби и спавнеры + if (this.spawnerManager) this.spawnerManager.stop(); + if (this.zombieManager) this.zombieManager.stop(); + // Выключаем NPC (удаляет их модели и UI). + if (this.npcManager) this.npcManager.stop(); + // Выключаем связи объектов. + if (this.constraintManager) this.constraintManager.stop(); + // Выключаем лучи и следы. + if (this.beamManager) this.beamManager.stop(); + // Выключаем 3D-звук (останавливает активные звуки). + if (this.soundManager) this.soundManager.stop(); + + if (this.player) { + this.player.stop(); + this.player = null; + } + + // Возвращаем визуальный маркер спавна + this._setSpawnMarkerVisible(true); + this.primitiveManager?.setTriggersVisible(true); + + // Останавливаем физику и возвращаем объекты на исходные позиции + this.dynamics?.stop(); + this._restoreDynamicObjects(); + // Полный откат сцены: пересоздаём примитивы и модели из снимка — + // возвращаются удалённые скриптом объекты, откатываются цвет/ + // видимость/коллизия/повороты, исчезают заспавненные объекты. + this._restoreFullScene(); + + // Останавливаем фоновый звук + this.audioManager?.stop(); + + // Восстанавливаем редактор-камеру + const snap = this._editorCameraSnapshot; + // Создаём новую UniversalCamera-редактор (наша старая была уничтожена когда + // PlayerController сделал scene.activeCamera = playerCamera). + // На самом деле она НЕ уничтожилась — мы просто переключали activeCamera. + // Возвращаем её обратно. + this.scene.activeCamera = this.camera; + if (snap) { + this.camera.position = snap.position; + this.camera.rotation = snap.rotation; + } + this._editorCameraSnapshot = null; + } + + dispose() { + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } + if (this._ro) { + this._ro.disconnect(); + this._ro = null; + } + for (const { target, type, fn, opts } of this._listeners) { + target.removeEventListener(type, fn, opts); + } + this._listeners = []; + if (this.player) { + this.player.stop(); + this.player = null; + } + if (this.history) { + this.history.dispose(); + this.history = null; + } + if (this._gizmo) { + try { this._gizmo.dispose(); } catch (e) { /* ignore */ } + this._gizmo = null; + } + if (this._gizmoLayer) { + try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ } + this._gizmoLayer = null; + } + if (this.selection) { + this.selection.dispose(); + this.selection = null; + } + if (this.blockManager) { + this.blockManager.dispose(); + this.blockManager = null; + } + if (this.modelManager) { + this.modelManager.dispose(); + this.modelManager = null; + } + if (this.primitiveManager) { + this.primitiveManager.dispose(); + this.primitiveManager = null; + } + if (this.folderManager) { + this.folderManager.dispose(); + this.folderManager = null; + } + if (this.guiManager) { + this.guiManager.clear(); + this.guiManager = null; + } + if (this.inventory) { + this.inventory.clear(); + this.inventory = null; + } + if (this.dynamics) { + this.dynamics.dispose(); + this.dynamics = null; + } + if (this.audioManager) { + this.audioManager.dispose(); + this.audioManager = null; + } + if (this.assetManager) { + this.assetManager.dispose(); + this.assetManager = null; + } + if (this.soundLibrary) { + this.soundLibrary.dispose(); + this.soundLibrary = null; + } + if (this.glbLibrary) { + this.glbLibrary.dispose(); + this.glbLibrary = null; + } + this.environment = null; + this.physics = null; + if (this.scene) { + this.scene.dispose(); + this.scene = null; + } + if (this.engine) { + this.engine.dispose(); + this.engine = null; + } + } +} diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index c376a49..4b77f8c 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -1,3917 +1,4115 @@ -/** - * GameRuntime — управляет всеми пользовательскими скриптами в режиме Play. - * - * Жизненный цикл: - * const rt = new GameRuntime(scene3d); - * rt.setOnLog(({level,text}) => console.log(text)); - * rt.start(scripts); // scripts — массив { id, code } - * ... каждый кадр rt.tick(dt) ... - * rt.stop(); // выгрузить всех Worker'ов - * - * Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь - * и применяются к BabylonScene (через player.teleport и т.п.). - * - * Этап 2.1: минимальный API — player.teleport, onTick, log. - */ - -import { Color3 } from '@babylonjs/core'; -import { ScriptSandbox } from './ScriptSandbox'; -import { STORYS_addres } from '../api/API'; - -export class GameRuntime { - constructor(scene3d) { - this.scene3d = scene3d; - /** @type {ScriptSandbox[]} */ - this.sandboxes = []; - this._onLog = null; - this._isRunning = false; - // Активные твины (game.tween). Крутятся в tick(dt). - // Каждый: { tweenId, scriptId, ref, props, from, duration, easing, - // delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft } - this._tweens = []; - // Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }. - // Общие для всех скриптов, рассылаются воркерам через dataSnapshot. - this._objectData = {}; - // Интерактивные объекты (game.self.onInteract / ProximityPrompt). - // Каждый: { target, text, distance, key }. Заполняется при - // self.registerInteract, проверяется по дистанции в tick. - this._interactables = []; - // ref ближайшего интерактивного объекта в зоне (для подсветки [E]). - this._activeInteractRef = null; - // Общее состояние комнаты для game.room.set/get (Фаза 4.3). - // В редакторе (single-player) — локальное хранилище. С Colyseus- - // комнатой будет синхронизироваться (требует серверной схемы). - this._roomState = {}; - // Сессии игроков, которых видели в прошлом tick — для детекта - // join/leave (game.onPlayerJoin / onPlayerLeave). - this._seenSessions = null; - // Команды (Фаза 4.4): name → { name, color }. - this._teams = new Map(); - // Команда локального игрока (имя) или null. - this._localPlayerTeam = null; - } - - setOnLog(cb) { this._onLog = cb; } - - /** Колбэк HUD-команд от скриптов: { cmd, payload }. */ - setOnHud(cb) { this._onHud = cb; } - - /** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */ - setOnCrosshairChange(cb) { this._onCrosshair = cb; } - - /** - * Запустить все скрипты. - * @param {Array<{id:any, code:string}>} scripts - */ - start(scripts) { - this.stop(); - this._isRunning = true; - // eslint-disable-next-line no-console - console.log('[GameRuntime] start called with scripts:', scripts); - if (!Array.isArray(scripts) || scripts.length === 0) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] start: no scripts to run'); - return; - } - // Карта модулей для game.require — { имя_скрипта: код }. - // Любой скрипт проекта можно подключить как модуль по его имени. - const modules = {}; - for (const s of scripts) { - if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') { - modules[s.name] = s.code; - } - } - // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и - // передаём прямо в init. Иначе findOne() в синхронном теле скрипта - // (на старте) возвращает null → подписки obj.onTouch/find не работают. - let initialScene = null; - try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } - for (const s of scripts) { - if (!s || typeof s.code !== 'string' || !s.code.trim()) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] skipping invalid script entry', s); - continue; - } - const sb = new ScriptSandbox(s.code, s.target || null); - sb.scriptId = s.id; - sb.setModules(modules); - if (initialScene) sb.setInitialScene(initialScene); - // Если target есть — передаём начальную позицию self до старта - if (s.target) { - const pos = this._collectSelfPosition(s.target); - if (pos) sb.setInitialSelfPosition(pos); - } - sb.setOnCommand((cmd, payload) => { - // PERF-METRICS: замер скриптов (postMessage→handle) - const _t0 = performance.now(); - this._handleCommand(s.id, cmd, payload); - const m = this.scene3d?._perfMetrics; - if (m) { - m.script_ms_sum += performance.now() - _t0; - m.script_count++; - } - }); - sb.start(); - this.sandboxes.push(sb); - // eslint-disable-next-line no-console - console.log('[GameRuntime] sandbox started for script id=', s.id); - } - this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); - // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' - // во все sandbox'ы. Не перезаписываем существующий обработчик — - // оборачиваем его (старый колбэк UI должен продолжать работать). - try { - const player = this.scene3d?.player; - if (player && !player._gameRuntimeHpHook) { - const prevCb = player._onHpChange; - this._lastSeenHp = player.hp ?? 100; - player._onHpChange = (ev) => { - if (typeof prevCb === 'function') { - try { prevCb(ev); } catch (e) {} - } - const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0); - this._lastSeenHp = ev?.hp ?? 0; - this.routeGlobalEvent('hpChange', { - hp: ev?.hp, - maxHp: ev?.maxHp, - source: ev?.source || null, - damaged: !!ev?.damaged, - delta, - }); - }; - player._gameRuntimeHpHook = true; - } - // Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand - if (player && !player._gameRuntimeMoveHook) { - player._onJump = () => this.routeGlobalEvent('playerJump', {}); - player._onLand = () => this.routeGlobalEvent('playerLand', {}); - player._gameRuntimeMoveHook = true; - } - // Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick - this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0; - // Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие - // npcDeath с id и позицией погибшего NPC. - const nm = this.scene3d?.npcManager; - if (nm && typeof nm.setOnDeath === 'function') { - nm.setOnDeath((npcId, position) => { - this.routeGlobalEvent('npcDeath', { npcId, position }); - }); - } - } catch (e) { /* ignore */ } - // Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала. - const sendInitial = () => { - this._broadcastSceneSnapshot(); - this._broadcastGuiSnapshot(); - this._broadcastTerrainHeightmap(); - this._broadcastSkinsSnapshot(); // задача 07 - // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'. - this._startGuiAnimationPresets(); - }; - if (typeof requestAnimationFrame !== 'undefined') { - requestAnimationFrame(sendInitial); - } else { - setTimeout(sendInitial, 16); - } - } - - /** - * Разослать карту высот гладкого ландшафта всем sandbox'ам. - * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по - * реальному мешу один раз — террейн в Play не меняется. - */ - _broadcastTerrainHeightmap() { - const s = this.scene3d; - if (!s || typeof s.exportRobloxHeightmap !== 'function') return; - // Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби - // (там шаг 2), для плавности движения животных достаточно. - let hm; - try { - hm = s.exportRobloxHeightmap(3); - } catch (e) { - return; - } - if (!hm || !hm.heights) return; - const payload = { - origin: hm.origin, step: hm.step, - cols: hm.cols, rows: hm.rows, heights: hm.heights, - }; - for (const sb of this.sandboxes) { - sb.sendTerrainHeightmap(payload); - } - } - - /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ - _startGuiAnimationPresets() { - const gm = this.scene3d?.guiManager; - if (!gm) return; - if (!this._guiTweens) this._guiTweens = []; - for (const el of (gm.elements || [])) { - const preset = el.animationPreset; - if (!preset || preset === 'none') continue; - const id = el.id; - // Каждый пресет = одна tween-запись с reverses+repeat=-1 - switch (preset) { - case 'pulse': - this._guiTweens.push(this._mkGuiPreset(id, el, - { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1)); - break; - case 'rotate': - this._guiTweens.push(this._mkGuiPreset(id, el, - { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1)); - break; - case 'sway': - this._guiTweens.push(this._mkGuiPreset(id, el, - { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1)); - this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8; - break; - case 'glow': - this._guiTweens.push(this._mkGuiPreset(id, el, - { bgOpacity: 0.6 }, 0.8, 'ease', true, -1)); - break; - case 'bounce': - this._guiTweens.push(this._mkGuiPreset(id, el, - { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1)); - break; - } - } - } - _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) { - const start = {}; - for (const k of Object.keys(targetProps)) { - if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1; - else if (k === 'rotation') start[k] = el.rotation || 0; - else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity; - else start[k] = el[k] || 0; - } - return { - tweenId: ++this._tweenSeq || (this._tweenSeq = 1), - scriptId: '__preset__', - realId: id, - start, target: targetProps, - elapsed: 0, delay: 0, - duration, easing, - repeat, reverses, iter: 0, dir: 1, - }; - } - - /** - * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы - * game.player.getAvailableSkins/getAllSkins работали синхронно. - * Манифест грузится через fetch (кешируется браузером), затем - * объединяется с разблокированными скинами из scene.skins. - */ - async _broadcastSkinsSnapshot() { - try { - this._ensureSkinState(); - let manifest = this._skinManifestCache; - if (!manifest) { - const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); - const json = await resp.json(); - manifest = (json.skins || []).map(s => ({ - slug: s.slug || (s.id || '').replace(/^skin_/, ''), - name: s.name || s.slug, - kind: s.kind || 'r15', - category: s.category || 'human', - price: Number.isFinite(s.price) ? s.price : 0, - })); - // Встроенные «человеки» character-a..g тоже добавим как базовый выбор. - this._skinManifestCache = manifest; - } - const payload = { - all: manifest, - unlocked: Array.from(this._skinState.unlocked), - current: this._skinState.current, - coins: this._skinState.coins, - }; - for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload); - // Также отдать снапшот в scene для React-магазина. - try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {} - } catch (e) { - // манифест недоступен — не критично, скрипт получит пустой список - } - } - - /** - * Задача 07: гарантированно инициализировать состояние скинов при первом - * обращении. Держит множество разблокированных скинов и текущий. - */ - _ensureSkinState() { - if (this._skinState) return this._skinState; - const sk = this.scene3d?._skinsConfig || {}; - const def = sk.default || this.scene3d?._playerModelType || 'character-a'; - const defSlug = this._slugFromTypeId(def); - const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []); - unlocked.add(defSlug); - this._skinState = { - unlocked, - current: defSlug, - shopVisible: sk.shopVisible !== false, - coins: Number.isFinite(sk.coins) ? sk.coins : 0, - }; - return this._skinState; - } - - /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ - _resolveSkinTypeId(slug) { - if (!slug) return 'character-a'; - if (slug.startsWith('character-')) return slug; - if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug; - return 'skin_' + slug; - } - - /** _modelTypeId → slug (обратно). */ - _slugFromTypeId(typeId) { - if (!typeId) return 'character-a'; - if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length); - return typeId; - } - - /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */ - _updateGuiTweens(dt) { - const gm = this.scene3d?.guiManager; - if (!gm) return; - for (let i = this._guiTweens.length - 1; i >= 0; i--) { - const tw = this._guiTweens[i]; - if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; } - tw.elapsed += dt; - let t = tw.elapsed / tw.duration; - let done = false; - if (t >= 1) { t = 1; done = true; } - const raw = tw.dir === -1 ? 1 - t : t; - const k = GameRuntime._ease(tw.easing, raw); - // Применяем - const el = gm.elements.find(e => e.id === tw.realId); - if (!el) { this._guiTweens.splice(i, 1); continue; } - const patch = {}; - for (const key of Object.keys(tw.target)) { - const from = tw.start[key]; - const to = tw.target[key]; - if (typeof from === 'number' && typeof to === 'number') { - patch[key] = from + (to - from) * k; - } else if (typeof from === 'string' && typeof to === 'string' - && from.startsWith('#') && to.startsWith('#')) { - patch[key] = GameRuntime._lerpColor(from, to, k); - } else { - // Прочее — на конце ставим целевое - if (done) patch[key] = to; - } - } - // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS). - tw._lastApply = tw._lastApply || 0; - tw._lastApply += dt; - if (tw._lastApply >= 0.032 || done) { - tw._lastApply = 0; - try { gm.update(tw.realId, patch); } catch (e) {} - } - - if (done) { - if (tw.reverses && tw.dir === 1) { - tw.dir = -1; - tw.elapsed = 0; - continue; - } - tw.iter++; - if (tw.repeat === -1 || tw.iter < tw.repeat) { - // повтор - tw.elapsed = 0; - tw.dir = 1; - continue; - } - // готово - this._guiTweens.splice(i, 1); - // onDone callback в worker - const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId); - if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId }); - } - } - } - - /** Слить отложенные команды для конкретного только что зарезолвленного ref. */ - _drainPendingResolveQueue(resolvedLocalRef) { - if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return; - const stay = []; - for (const item of this._pendingResolveQueue) { - if (item.payload?.ref === resolvedLocalRef) { - this._handleCommand(item.scriptId, item.cmd, item.payload); - } else { - stay.push(item); - } - } - this._pendingResolveQueue = stay; - } - - /** - * Получить позицию объекта по его target (для зеркалирования в worker). - */ - _collectSelfPosition(target) { - if (!target || !this.scene3d) return null; - try { - if (target.kind === 'block') { - const r = target.ref || target; - return { x: r.x, y: r.y + 0.5, z: r.z }; - } - if (target.kind === 'model') { - const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - if (target.kind === 'primitive') { - const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - if (target.kind === 'userModel') { - const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - } catch (e) { /* ignore */ } - return null; - } - - stop() { - if (this.sandboxes.length > 0) { - this._log('info', 'Остановка скриптов'); - // eslint-disable-next-line no-console - console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); - for (const sb of this.sandboxes) sb.stop(); - } - // Удаляем все объекты, которые скрипты наспавнили через - // game.scene.spawn/clone — иначе после Stop они остаются на сцене - // и накапливаются при повторных запусках. - this._cleanupSpawnedObjects(); - // Удаляем GUI-элементы, созданные скриптом через game.gui.create — - // иначе после Stop они остаются в интерфейсе сцены. - this._cleanupSpawnedGui(); - // Убираем billboard-метки над объектами (game.scene.setLabel). - try { - if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll(); - } catch (e) { /* ignore */ } - this.sandboxes = []; - this._isRunning = false; - this._soloScriptId = null; - this._tweens = []; - this._objectData = {}; - this._interactables = []; - this._activeInteractRef = null; - this._watchedTouchRefs = null; - this._watchedClickRefs = null; - this._roomState = {}; - this._seenSessions = null; - this._teams = new Map(); - this._localPlayerTeam = null; - this._constraintLocalToReal = new Map(); - this._fxLocalToReal = new Map(); - this._soundLocalToReal = new Map(); - this._guiLocalToReal = new Map(); - this._guiRealToLocal = new Map(); - } - - /** - * Удалить GUI-элементы, созданные скриптом через game.gui.create. - * Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене - * после остановки игры и копится при повторных запусках. - */ - _cleanupSpawnedGui() { - if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return; - const s = this.scene3d; - if (!s || typeof s.removeGuiElement !== 'function') return; - for (const realId of this._guiLocalToReal.values()) { - try { - // removeGuiElement каскадно удаляет детей — повторный вызов - // для уже удалённого элемента безопасен (no-op). - s.removeGuiElement(realId); - } catch (e) { /* ignore */ } - } - // removeGuiElement дёргает _notify GuiManager → KubikonEditor - // синхронит guiList. Снапшот воркерам не нужен (они остановлены). - } - - /** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */ - _cleanupSpawnedObjects() { - if (!this._localToReal || this._localToReal.size === 0) return; - const s = this.scene3d; - for (const realRef of this._localToReal.values()) { - try { - if (typeof realRef !== 'string') continue; - const colon = realRef.indexOf(':'); - if (colon < 0) continue; - const kind = realRef.slice(0, colon); - const rest = realRef.slice(colon + 1); - if (kind === 'block') { - const [xs, ys, zs] = rest.split(','); - s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); - } else if (kind === 'model') { - s?.modelManager?.removeInstance(Number(rest)); - } else if (kind === 'primitive') { - s?.primitiveManager?.removeInstance(Number(rest)); - } - } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } - } - this._localToReal = new Map(); - } - - /** - * Запустить ОДИН скрипт без перезагрузки сцены — режим отладки. - * Останавливает другие скрипты, оставляет только заданный. - * Это альтернатива Play-режиму: без полноценного игрока, без физики, но - * скрипты получают зеркало state и могут вызывать game.log/teleport. - * - * Используется из ScriptEditor → кнопка «Запустить только этот». - */ - startSolo(script) { - this.stop(); - this._isRunning = true; - this._soloScriptId = script?.id || null; - if (!script || typeof script.code !== 'string' || !script.code.trim()) { - this._log('warn', 'Solo-запуск: пустой код'); - return; - } - const sb = new ScriptSandbox(script.code, script.target || null); - sb.scriptId = script.id; - if (script.target) { - const pos = this._collectSelfPosition(script.target); - if (pos) sb.setInitialSelfPosition(pos); - } - sb.setOnCommand((cmd, payload) => { - const _t0 = performance.now(); - this._handleCommand(script.id, cmd, payload); - const m = this.scene3d?._perfMetrics; - if (m) { - m.script_ms_sum += performance.now() - _t0; - m.script_count++; - } - }); - sb.start(); - this.sandboxes.push(sb); - this._log('info', `Отладочный запуск: ${script.id}`); - } - - /** True если runtime работает в solo-режиме (один скрипт). */ - isSolo() { return !!this._soloScriptId; } - getSoloScriptId() { return this._soloScriptId; } - - /** - * Вызывать каждый кадр в Play-режиме. - * dt в секундах. - */ - tick(dt) { - if (!this._isRunning || this.sandboxes.length === 0) return; - const state = this._collectState(); - for (const sb of this.sandboxes) { - // Для скриптов с target — добавляем актуальную позицию self - const stateForSb = sb.target - ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } - : state; - sb.tick(dt, stateForSb); - } - // Анимации game.tween - if (this._tweens.length > 0) this._updateTweens(dt); - // Задача 03: GUI tweens - if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt); - - // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом - if (this._interactables.length > 0) this._updateInteractables(); - - // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть) - const hp = this.scene3d?.player?.hp ?? 100; - const aliveNow = hp > 0; - if (this._playerWasAlive && !aliveNow) { - this.routeGlobalEvent('playerDied', {}); - } - this._playerWasAlive = aliveNow; - - // Детект join/leave игроков комнаты (Фаза 4.3). - this._detectPlayerJoinLeave(state.players); - } - - /** - * Сравнить текущий список игроков с прошлым tick — событие - * playerJoin для новых, playerLeave для исчезнувших. - * Локального игрока не учитываем (он не «присоединяется»). - */ - _detectPlayerJoinLeave(players) { - if (!players || !players.list) return; - const now = new Map(); - for (const p of players.list) { - if (!p.isLocal) now.set(p.sessionId, p); - } - if (this._seenSessions == null) { - // Первый tick — фиксируем без событий (это «уже были»). - this._seenSessions = now; - return; - } - for (const [sid, p] of now) { - if (!this._seenSessions.has(sid)) { - this.routeGlobalEvent('playerJoin', { - sessionId: sid, name: p.name, - }); - } - } - for (const [sid, p] of this._seenSessions) { - if (!now.has(sid)) { - this.routeGlobalEvent('playerLeave', { - sessionId: sid, name: p.name, - }); - } - } - this._seenSessions = now; - } - - /** - * Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens. - * payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo } - */ - _startTween(scriptId, payload) { - try { - const { tweenId, ref, props } = payload || {}; - if (tweenId == null || typeof ref !== 'string' || !props) return; - const from = {}; - let guiId = null; - - // --- цель: GUI или 3D-объект --- - // GUI-id: либо локальный ref (gui.create), либо реальный id - let resolvedGuiId = ref; - if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref); - const guiList = this.scene3d?.getGuiElements?.() || []; - const guiEl = guiList.find(g => g.id === resolvedGuiId); - - if (guiEl) { - guiId = resolvedGuiId; - // числовые свойства GUI - for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) { - if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]); - } - // цвет - if (props.color != null && guiEl.bgColor) { - from._color = Color3.FromHexString(guiEl.bgColor); - from._colorTo = Color3.FromHexString(String(props.color)); - } - if (props.textColor != null && guiEl.textColor) { - from._color = Color3.FromHexString(guiEl.textColor); - from._colorTo = Color3.FromHexString(String(props.textColor)); - } - } else { - // 3D-объект - const tgt = this._resolveTweenTarget(ref); - if (!tgt) { - this._log('error', 'tween: объект не найден — ' + ref); - return; - } - const d = tgt.data; - from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0; - from.rotationX = d.rotationX || 0; - from.rotationY = d.rotationY || 0; - from.rotationZ = d.rotationZ || 0; - from.sx = d.sx != null ? d.sx : 1; - from.sy = d.sy != null ? d.sy : 1; - from.sz = d.sz != null ? d.sz : 1; - from.opacity = d.opacity != null ? d.opacity - : (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1); - if (props.color != null) { - const cur = d.color || '#ffffff'; - from._color = Color3.FromHexString(cur); - from._colorTo = Color3.FromHexString(String(props.color)); - } - } - - this._tweens.push({ - tweenId, scriptId, ref, guiId, - props, from, - duration: Math.max(0, Number(payload.duration) || 0), - easing: payload.easing || 'ease', - delayLeft: Math.max(0, Number(payload.delay) || 0), - loopsLeft: Number(payload.repeat) || 0, // 0=без повтора, -1=бесконечно - yoyo: !!payload.yoyo, - elapsed: 0, - dir: 1, - }); - } catch (e) { - this._log('error', 'tween.start failed: ' + (e?.message || e)); - } - } - - /** - * ProximityPrompt: каждый кадр ищем ближайший интерактивный объект - * в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка). - */ - _updateInteractables() { - const player = this.scene3d?.player; - const pp = player?._pos; - if (!pp) return; - const halfH = player?.HALF_H ?? 0.9; - const px = pp.x, py = pp.y - halfH, pz = pp.z; - - let nearest = null; - let nearestD2 = Infinity; - for (const it of this._interactables) { - const objPos = this._resolveInteractPos(it); - if (!objPos) continue; - const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz; - const d2 = dx*dx + dy*dy + dz*dz; - const r = it.distance; - if (d2 <= r*r && d2 < nearestD2) { - nearestD2 = d2; - nearest = it; - } - } - - const nearestRef = nearest ? nearest.ref : null; - if (nearestRef !== this._activeInteractRef) { - this._activeInteractRef = nearestRef; - if (nearest) { - // показываем подсказку через HUD (как game.ui.set) - if (this._onHud) { - try { - this._onHud({ cmd: 'ui.set', payload: { - id: '__interact', - text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text, - opts: { x: 50, y: 75, color: '#ffe44a', size: 20 }, - } }); - } catch (e) { /* ignore */ } - } - } else { - // вышли из зоны — убираем подсказку - if (this._onHud) { - try { - this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } }); - } catch (e) { /* ignore */ } - } - } - } - } - - /** Резолв позиции интерактивного объекта (по ref). */ - _resolveInteractPos(it) { - const tgt = this._resolveTweenTarget(it.ref); - if (tgt) { - const d = tgt.data; - return { x: d.x || 0, y: d.y || 0, z: d.z || 0 }; - } - return null; - } - - /** - * Нажата клавиша взаимодействия (E) — отправить событие 'interact' - * скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent - * при keydown. - */ - _tryInteract(key) { - if (!this._activeInteractRef) return; - const it = this._interactables.find(x => x.ref === this._activeInteractRef); - if (!it || it.key !== String(key).toLowerCase()) return; - // событие 'interact' скрипту с target = этим объектом - this.routeEvent(it.target, 'interact', {}); - } - - /** Прокрутка всех активных твинов на dt секунд. */ - _updateTweens(dt) { - for (let i = this._tweens.length - 1; i >= 0; i--) { - const tw = this._tweens[i]; - // задержка перед стартом - if (tw.delayLeft > 0) { - tw.delayLeft -= dt; - if (tw.delayLeft > 0) continue; - dt = -tw.delayLeft; // остаток времени уходит в анимацию - } - tw.elapsed += dt; - let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1; - let done = false; - if (t >= 1) { - t = 1; - done = true; - } - // прогресс с учётом направления (yoyo) + easing - const raw = tw.dir === -1 ? 1 - t : t; - const k = GameRuntime._ease(tw.easing, raw); - this._applyTweenFrame(tw, k); - - if (done) { - if (tw.yoyo && tw.dir === 1) { - // первый проход «туда» завершён — разворачиваем «обратно» - tw.dir = -1; - tw.elapsed = 0; - continue; - } - // цикл завершён полностью (или прямой, или yoyo туда-обратно) - if (tw.loopsLeft !== 0) { - if (tw.loopsLeft > 0) tw.loopsLeft--; - tw.dir = 1; - tw.elapsed = 0; - continue; - } - // твин закончен — снять и уведомить скрипт - this._tweens.splice(i, 1); - this._notifyTweenDone(tw.scriptId, tw.tweenId); - } - } - } - - /** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */ - static _ease(name, t) { - switch (name) { - case 'linear': - return t; - case 'bounce': { - const n1 = 7.5625, d1 = 2.75; - if (t < 1 / d1) return n1 * t * t; - if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } - if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } - t -= 2.625 / d1; return n1 * t * t + 0.984375; - } - case 'elastic': { - if (t === 0 || t === 1) return t; - const c4 = (2 * Math.PI) / 3; - return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; - } - case 'back': { - const c1 = 1.70158, c3 = c1 + 1; - return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); - } - case 'ease': - default: - // ease-in-out (плавный старт и финиш) - return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - } - } - - /** Уведомить воркер скрипта что твин доиграл (resolve onDone). */ - _notifyTweenDone(scriptId, tweenId) { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && sb.worker) { - try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {} - } - } - - /** - * Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после - * scene.spawn. Нужно чтобы синхронные read-методы воркера - * (getPosition и т.п.) резолвили локальный ref в реальный — иначе - * заспавненный объект не находится в _sceneIndex (там реальные ref). - */ - _notifySpawnResolved(localRef, realRef) { - if (!localRef || !realRef) return; - // Объект мог быть удалён скриптом ДО того как зарезолвился - // (асинхронный спавн GLB-модели). Если он в очереди отложенных - // удалений — удаляем сейчас, когда реальный id известен. - if (this._pendingDeletes && this._pendingDeletes.has(localRef)) { - this._pendingDeletes.delete(localRef); - try { - this._applySceneDelete({ ref: realRef }); - } catch (e) { /* ignore */ } - return; - } - for (const sb of this.sandboxes) { - if (sb && sb.worker) { - try { - sb.worker.postMessage({ - cmd: 'spawnResolved', - payload: { localRef, realRef }, - }); - } catch (e) { /* ignore */ } - } - } - } - - /** - * Резолв ref в инстанс-данные объекта сцены. - * Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'. - * data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z). - */ - /** - * Резолв id примитива из любого вида ссылки в реальный id для - * primitiveManager.instances. Принимает: - * - реальный числовой id (или строку-число) - * - локальный ref от spawn/clone ('primitive:_local_N') - * - ref 'primitive:realId' - * Возвращает id (число) или null. - */ - _resolvePrimitiveId(idOrRef) { - if (idOrRef == null) return null; - const pm = this.scene3d?.primitiveManager; - if (!pm) return null; - let v = idOrRef; - if (typeof v === 'string') { - // полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту - if (this._localToReal?.has(v)) v = this._localToReal.get(v); - const colon = v.indexOf(':'); - if (colon >= 0) v = v.slice(colon + 1); - // голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте: - // ключ 'primitive:_local_N' → значение 'primitive:realId'. - if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) { - const full = 'primitive:' + v; - if (this._localToReal.has(full)) { - const real = this._localToReal.get(full); - const c2 = real.indexOf(':'); - v = c2 >= 0 ? real.slice(c2 + 1) : real; - } - } - } - // прямой id - if (pm.instances.has(v)) return v; - const n = Number(v); - if (Number.isFinite(n) && pm.instances.has(n)) return n; - return null; - } - - /** - * ref NPC ('npc:_local_N' от воркера или 'npc:') → числовой npcId. - * Возвращает number или null. - */ - _resolveNpcId(ref) { - if (typeof ref !== 'string') return null; - let v = ref; - // Локальный ref воркера → реальный 'npc:'. - if (this._localToReal?.has(v)) v = this._localToReal.get(v); - const colon = v.indexOf(':'); - if (colon < 0) return null; - const id = Number(v.slice(colon + 1)); - return Number.isFinite(id) ? id : null; - } - - /** - * Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а - * скрипт сразу зовёт follow/moveTo/say) — откладываем команду в - * очередь по локальному ref и проигрываем после npcSpawned-резолва. - * Без этого команды сразу после spawnNpc молча терялись. - */ - _npcCmd(ref, fn) { - const nid = this._resolveNpcId(ref); - if (nid != null) { fn(nid); return; } - // ещё не резолвится — откладываем (только для локальных ref NPC) - if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) { - if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map(); - if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); - this._pendingNpcCmds.get(ref).push(fn); - } - } - - /** Проиграть отложенные команды для NPC после его резолва. */ - _flushPendingNpcCmds(localRef, npcId) { - if (!this._pendingNpcCmds) return; - const queue = this._pendingNpcCmds.get(localRef); - if (!queue) return; - this._pendingNpcCmds.delete(localRef); - for (const fn of queue) { - try { fn(npcId); } catch (e) { /* ignore */ } - } - } - - /** Локальный ref связи ('constraint:_local_N') → числовой id или null. */ - _resolveConstraintId(ref) { - if (typeof ref !== 'string') return null; - if (this._constraintLocalToReal?.has(ref)) { - return this._constraintLocalToReal.get(ref); - } - // Запасной путь: прямой числовой id в строке. - const colon = ref.indexOf(':'); - const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); - return Number.isFinite(id) ? id : null; - } - - /** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */ - _resolveFxId(ref) { - if (typeof ref !== 'string') return null; - if (this._fxLocalToReal?.has(ref)) { - return this._fxLocalToReal.get(ref); - } - const colon = ref.indexOf(':'); - const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); - return Number.isFinite(id) ? id : null; - } - - _resolveTweenTarget(ref) { - if (typeof ref !== 'string') return null; - // Локальный ref из scene.spawn ('primitive:_local_N') → реальный id - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - const colon = ref.indexOf(':'); - const kind = colon >= 0 ? ref.slice(0, colon) : null; - const rawId = colon >= 0 ? ref.slice(colon + 1) : ref; - const tryGet = (mgr) => { - if (!mgr || !mgr.instances) return null; - let d = mgr.instances.get(rawId); - if (!d) { - const n = Number(rawId); - if (Number.isFinite(n)) d = mgr.instances.get(n); - } - return d || null; - }; - if (kind === 'primitive' || kind == null) { - const d = tryGet(this.scene3d?.primitiveManager); - if (d) return { kind: 'primitive', data: d }; - } - if (kind === 'model' || kind == null) { - const d = tryGet(this.scene3d?.modelManager); - if (d) return { kind: 'model', data: d }; - } - const um = tryGet(this.scene3d?.userModelManager); - if (um) return { kind: 'userModel', data: um }; - return null; - } - - /** - * Применить промежуточное состояние твина к объекту. - * k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу. - */ - _applyTweenFrame(tw, k) { - const lerp = (a, b) => a + (b - a) * k; - // --- GUI-элемент --- - if (tw.guiId != null) { - const patch = {}; - for (const key of Object.keys(tw.props)) { - if (key === 'color' || key === 'textColor') continue; - if (tw.from[key] == null) continue; - patch[key] = lerp(tw.from[key], Number(tw.props[key])); - } - if (tw.props.color != null || tw.props.textColor != null) { - const ck = tw.props.color != null ? 'color' : 'textColor'; - patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k); - } - // обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр) - try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {} - return; - } - // --- 3D-объект --- - const tgt = this._resolveTweenTarget(tw.ref); - if (!tgt) return; - const d = tgt.data; - const p = tw.props, f = tw.from; - // позиция - let posChanged = false; - if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; } - if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; } - if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; } - // поворот - let rotChanged = false; - if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; } - if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; } - if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; } - // масштаб - let scaleChanged = false; - if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; } - if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; } - if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; } - // меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode) - const mesh = d.mesh || d.rootMesh || d.rootNode; - if (mesh) { - if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z); - if (rotChanged && mesh.rotation) { - mesh.rotation.x = d.rotationX || 0; - mesh.rotation.y = d.rotationY || 0; - mesh.rotation.z = d.rotationZ || 0; - } - if (scaleChanged && mesh.scaling) { - mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1); - } - // размороз world-matrix если был заморожен - if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) { - try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} - d._worldMatrixFrozen = false; - } - } - // цвет - if (p.color != null && f._color != null && mesh?.material) { - const c = GameRuntime._lerpColor3(f._color, f._colorTo, k); - mesh.material.diffuseColor = c; - if (d.material === 'neon') mesh.material.emissiveColor = c; - d.color = '#' + c.toHexString().slice(1); - } - // прозрачность - if (p.opacity != null && mesh?.material) { - const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity)); - mesh.material.alpha = op; - d.opacity = op; - } - } - - /** Интерполяция цвета (Babylon Color3) между двумя hex. */ - static _lerpColor3(from, to, k) { - return new Color3( - from.r + (to.r - from.r) * k, - from.g + (to.g - from.g) * k, - from.b + (to.b - from.b) * k, - ); - } - - /** Интерполяция цвета → hex-строка (для GUI). */ - static _lerpColor(from, to, k) { - return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1); - } - - /** - * Маршрутизация событий объектов к скриптам с соответствующим target. - * Вызывается из BabylonScene при клике/touch. - * - * @param {object} target — {kind, ref|x|y|z|id} - * @param {string} eventType — 'click' | 'touch' - * @param {object} extra — дополнительные данные события - */ - routeEvent(target, eventType, extra = {}) { - if (!target || !eventType) return; - for (const sb of this.sandboxes) { - if (!sb.target) continue; - if (!this._targetMatches(sb.target, target)) continue; - sb.sendEvent({ type: eventType, ...extra }); - } - } - - /** - * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target). - * Используется для onKey, onClick (глобальный), onPlayerTouch. - */ - routeGlobalEvent(eventType, extra = {}) { - if (!eventType) return; - // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя - // способами: - // 1) по локальному ref, который вернул gui.create() — '_gui_local_N' - // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }), - // или по name элемента. - // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2), - // потому что worker искал handler по localRef, а юзер подписался по - // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref), - // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker). - if ((eventType === 'guiClick' || eventType === 'guiSubmit' - || eventType === 'guiTextChange') - && extra && extra.id != null && this._guiRealToLocal) { - const local = this._guiRealToLocal.get(extra.id); - if (local && local !== extra.id) extra = { ...extra, localId: local }; - } - // ProximityPrompt: keydown клавиши взаимодействия → событие interact - if (eventType === 'keydown' && extra && extra.key - && this._interactables.length > 0) { - this._tryInteract(extra.key); - } - for (const sb of this.sandboxes) { - sb.sendGlobalEvent({ type: eventType, ...extra }); - } - } - - /** - * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref. - * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref; - * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick. - */ - routeInstEvent(ref, type, extra = {}) { - if (!ref || !type) return; - this.routeGlobalEvent(type, { ref, ...extra }); - } - - /** - * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. - * Скрипт может подписаться через `game.onMobKilled(fn)`. - * payload: { type: 'zombie' | ..., x, y, z } - */ - notifyMobKilled(mobType, position) { - this.routeGlobalEvent('mobKilled', { mobType, position }); - } - - /** Совпадает ли target скрипта с обращённым target события. */ - _targetMatches(a, b) { - if (!a || !b) return false; - if (a.kind !== b.kind) return false; - if (a.kind === 'block') { - const ar = a.ref || a; - const br = b.ref || b; - return ar.x === br.x && ar.y === br.y && ar.z === br.z; - } - const aId = a.id ?? a.ref; - const bId = b.id ?? b.ref; - return aId === bId; - } - - /** Собрать снимок state для отправки в Worker'ы. */ - _collectState() { - const player = this.scene3d?.player; - // PlayerController хранит позицию в this._pos (Vector3). - // Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов - // удобнее давать «низ ног» = _pos.y - HALF_H. - const p = player?._pos; - const halfH = player?.HALF_H ?? 0.9; - const position = p - ? { x: p.x, y: p.y - halfH, z: p.z } - : { x: 0, y: 0, z: 0 }; - // Yaw/pitch (для player.forward) - const yaw = player?._yaw || 0; - const pitch = player?._pitch || 0; - // Forward-вектор. PlayerController использует: - // fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch) - const cosP = Math.cos(pitch); - const forward = { - x: Math.sin(yaw) * cosP, - y: -Math.sin(pitch), - z: Math.cos(yaw) * cosP, - }; - const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none'; - const hp = player?.hp ?? 100; - const maxHp = player?.maxHp ?? 100; - // Снимок мобов (зомби) — для game.scene.mobs() из скриптов - let mobs = []; - try { - const zm = this.scene3d?.zombieManager; - if (zm && typeof zm.getMobsSnapshot === 'function') { - mobs = zm.getMobsSnapshot(); - } - } catch (e) {} - // Снимок NPC — для game.scene.npcs() и npc.position из скриптов. - let npcs = []; - try { - const nm = this.scene3d?.npcManager; - if (nm && typeof nm.getSnapshot === 'function') { - npcs = nm.getSnapshot(); - } - } catch (e) {} - // Снимок инвентаря — для game.inventory.has/list/active. - let inventory = null; - try { - const inv = this.scene3d?.inventory; - if (inv) { - inventory = { - slots: inv.slots.map(s => s ? { - kind: s.kind, modelTypeId: s.modelTypeId, name: s.name, - } : null), - activeIndex: inv.activeIndex, - }; - } - } catch (e) {} - // Снимок игроков комнаты — для game.players.* (Фаза 4.3). - // В редакторе (single-player) — только локальный игрок. - // С мультиплеером — локальный + все remote из _mpSync. - const players = this._collectPlayers(position, hp, maxHp); - // Кубикон Dash: текущее направление гравитации (+1 / -1). - // Нужно скрипту для рендера куба в правильной ориентации. - const gravityDir = player?._gravityDir ?? 1; - // Состояние игрока ('ground'|'air'|'water') для game.player.state. - const state = player?._playerState || 'ground'; - // Зажатые клавиши — для game.player.isKeyDown(key). - // _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта. - const keys = {}; - if (player?._codes) { - for (const code of player._codes) { - const k = GameRuntime._normalizeKeyCode(code); - if (k) keys[k] = true; - } - } - return { - player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys }, - mobs, - npcs, - inventory, - players, - roomState: this._roomState || {}, - teams: this._teams ? Array.from(this._teams.values()) : [], - }; - } - - /** - * Снимок всех игроков комнаты для game.players.* (Фаза 4.3). - * Локальный игрок всегда первый, sessionId='local' в одиночной игре - * или реальный sessionId если есть Colyseus-комната. - * Возвращает { me, list } — list включает me. - */ - _collectPlayers(myPos, myHp, myMaxHp) { - const mp = this.scene3d?._mpSync; - const mySessionId = mp?.room?.sessionId || 'local'; - const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username - || this._localPlayerName || 'Игрок'; - const me = { - sessionId: mySessionId, - name: myName, - isLocal: true, - position: myPos, - hp: myHp, maxHp: myMaxHp, - team: this._localPlayerTeam || null, - }; - const list = [me]; - // Remote-игроки из MultiplayerSync (если есть комната). - if (mp && mp.remotePlayers) { - const roomPlayers = mp.room?.state?.players; - for (const rp of mp.remotePlayers.values()) { - // team берётся из Colyseus-state (его синхронизирует сервер). - const colyP = roomPlayers?.get?.(rp.sessionId); - list.push({ - sessionId: rp.sessionId, - name: rp.username || rp.sessionId, - isLocal: false, - position: rp.current - ? { x: rp.current.x, y: rp.current.y, z: rp.current.z } - : { x: 0, y: 0, z: 0 }, - hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100, - team: (colyP && colyP.team) || null, - }); - } - } - return { me, list }; - } - - /** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */ - static _normalizeKeyCode(code) { - if (!code) return null; - if (code.startsWith('Key')) return code.slice(3).toLowerCase(); // KeyW → w - if (code.startsWith('Digit')) return code.slice(5); // Digit1 → 1 - if (code.startsWith('Arrow')) return code.toLowerCase(); // ArrowUp → arrowup - const map = { - Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift', - Enter: 'enter', Escape: 'escape', - ControlLeft: 'ctrl', ControlRight: 'ctrl', - }; - return map[code] || code.toLowerCase(); - } - - /** Команда от Worker'а пришла — применяем на сцене. */ - _handleCommand(scriptId, cmd, payload) { - if (cmd === 'log') { - this._log(payload?.level || 'info', payload?.text || ''); - return; - } - // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик - // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает - // следить за AABB этого объекта в _detectTouchEvents и слать обратно - // instTouch/instUntouch (через routeInstEvent). - if (cmd === 'inst.watchTouch') { - const ref = payload && payload.ref; - if (typeof ref === 'string') { - if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set(); - this._watchedTouchRefs.add(ref); - } - return; - } - if (cmd === 'inst.watchClick') { - const ref = payload && payload.ref; - if (typeof ref === 'string') { - if (!this._watchedClickRefs) this._watchedClickRefs = new Set(); - this._watchedClickRefs.add(ref); - } - return; - } - if (cmd === 'player.teleport') { - const player = this.scene3d?.player; - if (player && player._pos && payload) { - const { x, y, z } = payload; - if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { - try { - const halfH = player.HALF_H ?? 0.9; - // Конвертируем «низ ног» обратно в центр капсулы - player._pos.set(x, y + halfH, z); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] teleport failed', e); - } - } - } else { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) }); - } - return; - } - if (cmd === 'player.setLaneX') { - // Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для - // раннеров (смена полосы): teleport(x,y,z) затирал бы Z, - // отменяя продвижение autorun каждый кадр. - const player = this.scene3d?.player; - if (player && player._pos && payload) { - const x = Number(payload.x); - if (Number.isFinite(x)) { - try { player._pos.x = x; } catch (e) { /* ignore */ } - } - } - return; - } - if (cmd === 'player.damage') { - const player = this.scene3d?.player; - if (player && typeof player.takeDamage === 'function') { - const amt = Math.max(0, Number(payload?.amount) || 0); - if (amt > 0) { - // Если урон больше maxHp — обходим i-frames для kill(). - if (amt >= (player.maxHp ?? 100)) { - player._lastDamageTime = 0; // сбрасываем cooldown - } - try { player.takeDamage(amt, 'script'); } catch (e) {} - } - } - return; - } - if (cmd === 'player.heal') { - const player = this.scene3d?.player; - if (player && typeof player.hp === 'number') { - const amt = Math.max(0, Number(payload?.amount) || 0); - player.hp = Math.min(player.maxHp ?? 100, player.hp + amt); - if (player._onHpChange) { - try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {} - } - } - return; - } - if (cmd === 'player.respawn') { - const player = this.scene3d?.player; - if (player && player._pos) { - // Восстанавливаем HP - player.hp = player.maxHp ?? 100; - if (player._onHpChange) { - try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {} - } - // Возвращаем модель если была спрятана при смерти - if (player._modelRoot) player._modelRoot.setEnabled(true); - // Телепорт на spawnPoint сцены - const sp = this.scene3d?._spawnPoint - || this.scene3d?.scene?.metadata?.spawnPoint - || { x: 0, y: 1, z: 0 }; - const halfH = player.HALF_H ?? 0.9; - try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {} - // Сбросим скорость падения - if (player._velocity) { - try { player._velocity.set(0, 0, 0); } catch (e) {} - } - } - return; - } - if (cmd === 'player.setSpawn') { - // Назначить активную точку возрождения. Меняем scene3d._spawnPoint — - // им пользуется player.respawn и логика смерти. - const s = this.scene3d; - if (s && payload) { - let sp = null; - if (typeof payload.ref === 'string') { - // ref объекта: встаём НАД ним (центр + полувысота + зазор). - const ref = payload.ref; - if (ref.indexOf('block:') === 0) { - const [bx, by, bz] = ref.slice(6).split(',').map(Number); - if ([bx, by, bz].every(Number.isFinite)) { - sp = { x: bx, y: by + 1.1, z: bz }; - } - } else { - const tgt = this._resolveTweenTarget(ref); - if (tgt && tgt.data) { - const d = tgt.data; - const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1; - sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z }; - } - } - } else if (Number.isFinite(payload.x)) { - sp = { x: payload.x, y: payload.y, z: payload.z }; - } - if (sp && typeof s.setSpawnPoint === 'function') { - s.setSpawnPoint(sp.x, sp.y, sp.z); - } - } - return; - } - // === NPC API (Фаза 4.1) === - if (cmd === 'npc.spawn') { - // payload: { modelType, ref, x, y, z, rotationY, hp, name, speed } - const nm = this.scene3d?.npcManager; - if (nm && payload) { - if (!this._localToReal) this._localToReal = new Map(); - const p = nm.spawnNpc(payload.modelType, { - x: payload.x, y: payload.y, z: payload.z, - rotationY: payload.rotationY, - hp: payload.hp, name: payload.name, speed: payload.speed, - }); - Promise.resolve(p).then((npcId) => { - if (npcId == null) { - this._log('error', 'spawnNpc не удался: ' + payload.modelType); - return; - } - // Локальный ref воркера → реальный 'npc:'. - if (payload.ref) { - this._localToReal.set(payload.ref, 'npc:' + npcId); - // Проигрываем команды, отправленные скриптом сразу - // после spawnNpc (follow/moveTo/say) — они ждали - // резолва ref в очереди. - this._flushPendingNpcCmds(payload.ref, npcId); - } - // Сообщаем воркеру маппинг localRef → npcId, чтобы - // npc.onDeath по локальному ref находил правильного NPC. - if (payload.ref) { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && sb.worker) { - try { - sb.worker.postMessage({ - cmd: 'npcSpawned', - payload: { localRef: payload.ref, npcId }, - }); - } catch (e) { /* ignore */ } - } - } - }).catch((err) => { - this._log('error', 'spawnNpc failed: ' + (err?.message || err)); - }); - } - return; - } - if (cmd === 'npc.moveTo') { - // _npcCmd откладывает команду, если NPC ещё не создан (async). - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z)); - return; - } - if (cmd === 'npc.follow') { - this._npcCmd(payload?.ref, (nid) => { - // target — ref объекта или 'player'. Резолвим локальный ref - // в реальный (объект мог быть заспавнен скриптом). - let target = payload?.target; - if (typeof target === 'string' && this._localToReal?.has(target)) { - target = this._localToReal.get(target); - } - this.scene3d?.npcManager?.follow(nid, target); - }); - return; - } - if (cmd === 'npc.stop') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.stopNpc(nid)); - return; - } - if (cmd === 'npc.setSpeed') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.setSpeed(nid, payload?.speed)); - return; - } - if (cmd === 'npc.say') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration)); - return; - } - if (cmd === 'npc.damage') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.damage(nid, payload?.amount)); - return; - } - if (cmd === 'npc.remove') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.removeNpc(nid)); - return; - } - // === Constraints / связи объектов (Фаза 5) === - if (cmd === 'constraint.create') { - // payload: { kind: 'weld'|'hinge'|'spring', localRef, ... } - const cm = this.scene3d?.constraintManager; - if (cm && payload) { - let id = null; - if (payload.kind === 'weld') { - id = cm.addWeld(payload.refA, payload.refB); - } else if (payload.kind === 'hinge') { - id = cm.addHinge(payload.ref, { - pivotX: payload.pivotX, pivotZ: payload.pivotZ, - angle: payload.angle, - }); - } else if (payload.kind === 'spring') { - id = cm.addSpring(payload.ref, { - stiffness: payload.stiffness, damping: payload.damping, - }); - } - if (id == null) { - this._log('error', 'не удалось создать связь ' + payload.kind); - } else if (payload.localRef) { - // Маппинг localRef → реальный id (как у NPC). - if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map(); - this._constraintLocalToReal.set(payload.localRef, id); - } - } - return; - } - if (cmd === 'constraint.hingeAngle') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg); - return; - } - if (cmd === 'constraint.springPush') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) { - this.scene3d?.constraintManager?.pushSpring( - cid, payload?.vx, payload?.vy, payload?.vz); - } - return; - } - if (cmd === 'constraint.remove') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) this.scene3d?.constraintManager?.remove(cid); - return; - } - // === Beam / Trail — лучи и следы (Фаза 5.2) === - if (cmd === 'fx.create') { - // payload: { kind: 'beam'|'trail', localRef, ... } - const bm = this.scene3d?.beamManager; - if (bm && payload) { - let id = null; - if (payload.kind === 'beam') { - id = bm.addBeam({ - from: payload.from, to: payload.to, - 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') { - id = bm.addTrail(payload.ref, { - color: payload.color, width: payload.width, - lifetime: payload.lifetime, - }); - } - if (id == null) { - this._log('error', 'не удалось создать ' + payload.kind); - } else if (payload.localRef) { - if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); - this._fxLocalToReal.set(payload.localRef, id); - } - } - return; - } - if (cmd === 'fx.beamColor') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color); - 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') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) { - this.scene3d?.beamManager?.setBeamEndpoints( - fid, payload?.from, payload?.to); - } - return; - } - if (cmd === 'fx.remove') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.remove(fid); - return; - } - // === Звук — game.sound.* (Фаза 5.5) === - // Пользовательский звук из библиотеки проекта (Фаза 5.5). - // Встроенные пресеты ({name} без soundId) обрабатывает старый - // обработчик ниже — здесь только {soundId}. - if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') { - const sm = this.scene3d?.soundManager; - if (sm && this.scene3d?.soundLibrary?.count() > 0) { - // attachRef может быть локальным ref от scene.spawn — резолвим. - let attachRef = payload.attachRef; - if (typeof attachRef === 'string' && attachRef !== 'player' - && this._localToReal?.has(attachRef)) { - attachRef = this._localToReal.get(attachRef); - } - const instId = sm.play(payload.soundId, { - volume: payload.volume, - loop: payload.loop, - at: payload.at, - attachRef, - }); - if (instId != null && payload.localRef) { - if (!this._soundLocalToReal) this._soundLocalToReal = new Map(); - this._soundLocalToReal.set(payload.localRef, instId); - } - } - return; - } - if (cmd === 'sound.stop') { - const ref = payload?.ref; - if (ref != null && this.scene3d?.soundManager) { - const instId = this._soundLocalToReal?.has(ref) - ? this._soundLocalToReal.get(ref) : Number(ref); - if (Number.isFinite(instId)) { - this.scene3d.soundManager.stopSound(instId); - } - } - return; - } - // === Tool / инвентарь API (Фаза 4.2) === - if (cmd === 'inventory.give') { - // payload: { kind, modelTypeId, name, params } - const inv = this.scene3d?.inventory; - if (inv && payload) { - const idx = inv.add({ - kind: payload.kind || 'item', - modelTypeId: payload.modelTypeId || null, - name: payload.name || 'Предмет', - params: payload.params || {}, - }); - if (idx < 0) { - this._log('error', 'инвентарь полон — предмет не добавлен'); - } else if (payload.equip) { - // Сразу сделать активным и снарядить (для giveTool). - inv.setActive(idx); - const item = inv.slots[idx]; - if (item && item.kind === 'weapon' && this.scene3d?.weapons) { - try { this.scene3d.weapons.equip(item); } catch (e) {} - } - } - } - return; - } - if (cmd === 'inventory.remove') { - // payload: { modelTypeId? , name? } — убрать первый совпавший слот. - const inv = this.scene3d?.inventory; - if (inv && payload) { - const slots = inv.slots; - for (let i = 0; i < slots.length; i++) { - const s = slots[i]; - if (!s) continue; - const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId; - const matchName = payload.name && s.name === payload.name; - if (matchModel || matchName) { - // Если убираем активное оружие — снять модель из руки. - if (i === inv.activeIndex && this.scene3d?.weapons) { - try { this.scene3d.weapons.unequip(); } catch (e) {} - } - inv.removeSlot(i); - break; - } - } - } - return; - } - if (cmd === 'inventory.clear') { - const inv = this.scene3d?.inventory; - if (inv) { - try { this.scene3d?.weapons?.unequip(); } catch (e) {} - inv.clear(); - } - return; - } - // === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) === - if (cmd === 'room.set') { - // payload: { key, value } - if (payload && typeof payload.key === 'string') { - if (!this._roomState) this._roomState = {}; - const changed = this._roomState[payload.key] !== payload.value; - this._roomState[payload.key] = payload.value; - // Если есть Colyseus-комната — отправляем серверу (он - // обновит общее state; серверная схема — отдельная задача). - try { - this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', { - key: payload.key, value: payload.value, - }); - } catch (e) { /* ignore */ } - // Локально сразу рассылаем событие изменения всем скриптам. - if (changed) { - this.routeGlobalEvent('roomChange', { - key: payload.key, value: payload.value, - }); - } - } - return; - } - if (cmd === 'mp.sendTo') { - // payload: { sessionId, name, data } — адресное сообщение игроку. - if (payload) { - const mp = this.scene3d?._mpSync; - if (mp && mp.room && typeof mp.room.send === 'function') { - // С комнатой — через сервер (релей по sessionId). - try { - mp.room.send('scriptMessage', { - to: payload.sessionId, - name: payload.name, - data: payload.data, - }); - } catch (e) { /* ignore */ } - } else if (payload.sessionId === 'local') { - // Single-player: сообщение «себе» — доставляем сразу. - this.routeGlobalEvent('mpMessage', { - from: 'local', name: payload.name, data: payload.data, - }); - } - } - return; - } - // === Команды / Teams (Фаза 4.4) === - if (cmd === 'teams.create') { - // payload: { name, color } - if (payload && typeof payload.name === 'string' && payload.name) { - if (!this._teams) this._teams = new Map(); - this._teams.set(payload.name, { - name: payload.name, - color: typeof payload.color === 'string' ? payload.color : '#888888', - }); - } - return; - } - if (cmd === 'teams.remove') { - if (payload && this._teams) { - this._teams.delete(payload.name); - // Если игрок был в этой команде — сбрасываем. - if (this._localPlayerTeam === payload.name) { - this._localPlayerTeam = null; - } - } - return; - } - if (cmd === 'player.setTeam') { - // payload: { team } — null/'' убирает команду. - const t = payload?.team; - let applied = null; - if (t == null || t === '') { - this._localPlayerTeam = null; - applied = ''; - } else if (typeof t === 'string') { - // Назначаем только если команда существует. - if (this._teams?.has(t)) { - this._localPlayerTeam = t; - applied = t; - } else { - this._log('error', 'команда не создана: ' + t); - } - } - // С Colyseus-комнатой — синхронизируем команду на сервер, - // чтобы остальные игроки видели её в Player.team. - if (applied != null) { - try { - this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied }); - } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'player.setSpeed') { - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._speedMul = m; - } - return; - } - // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: - // game.player.equipAccessory(itemId) — надеть аксессуар прямо - // из скрипта игры (например выдать всем хеллоуинскую шапку при - // спавне). itemId — числовой id из rublox_items. - // Бэк фильтрует только published — на сервере ничего не настроишь. - if (cmd === 'player.equipAccessory') { - const player = this.scene3d?.player; - const itemId = Number(payload?.itemId); - if (!player || !Number.isFinite(itemId) || itemId <= 0) return; - (async () => { - try { - // Грузим item через публичный catalog (только published) - const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`); - if (!resp.ok) return; - const item = await resp.json(); - if (item && typeof player.equipAccessory === 'function') { - await player.equipAccessory(item); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] equipAccessory failed', e); - } - })(); - return; - } - if (cmd === 'player.unequipSlot') { - const player = this.scene3d?.player; - const slot = String(payload?.slot || ''); - if (player && slot && typeof player.unequipSlot === 'function') { - player.unequipSlot(slot); - } - return; - } - if (cmd === 'player.unequipAll') { - const player = this.scene3d?.player; - if (player && typeof player.unequipAll === 'function') { - player.unequipAll(); - } - return; - } - if (cmd === 'player.setJumpPower') { - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m; - } - return; - } - if (cmd === 'player.setGravityMul') { - // Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27). - // Не зависит от gravityDir — работает в обоих направлениях. - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._gravityMul = m; - } - return; - } - if (cmd === 'player.setShipMode') { - // GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). - const player = this.scene3d?.player; - if (player) player._shipMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setUfoMode') { - // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе. - const player = this.scene3d?.player; - if (player) player._ufoMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setWaveMode') { - // GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз). - const player = this.scene3d?.player; - if (player) player._waveMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setVy') { - // Прямое задание vy (для трамплинов, jump orb, boost-зон). - const player = this.scene3d?.player; - if (player) { - const v = Number(payload?.vy); - if (Number.isFinite(v)) player._vy = v; - } - return; - } - if (cmd === 'player.setRobotMode') { - // GD-гейммод Robot: variable-jump (высота = длительности удержания Space). - const player = this.scene3d?.player; - if (player) { - player._robotMode = !!payload?.enabled; - if (!player._robotMode) player._robotBoostLeft = 0; - } - return; - } - if (cmd === 'player.setDoubleJump') { - const player = this.scene3d?.player; - if (player) player._doubleJumpEnabled = !!payload?.enabled; - return; - } - if (cmd === 'player.playAnimation') { - const player = this.scene3d?.player; - if (player && typeof player.playEmote === 'function') { - const ok = player.playEmote(payload?.name); - if (!ok) { - this._log('error', 'playAnimation: эмоция не найдена — ' - + payload?.name + ' (доступно: wave, dance, cheer, sit)'); - } - } - return; - } - if (cmd === 'player.stopAnimation') { - const player = this.scene3d?.player; - if (player && typeof player.stopEmote === 'function') player.stopEmote(); - return; - } - if (cmd === 'player.setIceFriction') { - const player = this.scene3d?.player; - if (player) { - const v = Number(payload?.value); - if (Number.isFinite(v)) { - player._iceFriction = Math.max(0, Math.min(1, v)); - } - } - return; - } - if (cmd === 'player.setAutoRun') { - const player = this.scene3d?.player; - if (player) { - const s = Number(payload?.speed); - if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s); - } - return; - } - if (cmd === 'player.boostJump') { - const player = this.scene3d?.player; - if (player) { - const s = Number(payload?.strength); - if (Number.isFinite(s) && s > 0) { - // boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0) - const gDir = player._gravityDir || 1; - const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1); - player._vy = base * s * gDir; - } - } - return; - } - if (cmd === 'player.flipGravity') { - // Меняет направление гравитации (как blue orb в GD): +1 ↔ -1 - const player = this.scene3d?.player; - if (player) { - player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1; - // Сбрасываем "second jump used" чтобы после флипа доступен прыжок - player._doubleJumpUsed = false; - } - return; - } - if (cmd === 'player.setGravityDir') { - // Явно задать направление: dir=1 (вниз) или -1 (вверх). - const player = this.scene3d?.player; - if (player) { - const d = Number(payload?.dir); - if (d === 1 || d === -1) { - player._gravityDir = d; - player._doubleJumpUsed = false; - } - } - return; - } - if (cmd === 'player.getGravityDir') { - // Возвращает текущее значение через broadcast-style "reply" - // Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker - return; - } - // === HUD / Input / App === - if (cmd === 'hud.setVisible') { - try { - const v = !!payload?.visible; - this.scene3d?._setStdHudVisible?.(v); - } catch (e) {} - 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') { - try { - const mode = payload?.mode === 'ui' ? 'ui' : 'game'; - const player = this.scene3d?.player; - if (player?.setUiCursorMode) { - player.setUiCursorMode(mode === 'ui'); - if (mode === 'ui') { - try { document.exitPointerLock?.(); } catch (e) {} - // Подписываемся на mouse-события и транслируем в Worker. - if (player.setUiMouseMoveCallback) { - let lastMM = 0; - player.setUiMouseMoveCallback((x, y) => { - const now = performance.now(); - if (now - lastMM < 20) return; - lastMM = now; - this.routeGlobalEvent('mouseMove', { x, y }); - }); - } - if (player.setUiMouseDownCallback) { - player.setUiMouseDownCallback((x, y) => { - this.routeGlobalEvent('mouseDown', { x, y }); - }); - } - if (player.setUiMouseUpCallback) { - player.setUiMouseUpCallback((x, y) => { - this.routeGlobalEvent('mouseUp', { x, y }); - }); - } - } else if (player._requestPointerLockSafe) { - // Отписываемся при возврате в game-режим - if (player.setUiMouseMoveCallback) { - player.setUiMouseMoveCallback(null); - } - if (player.setUiMouseDownCallback) { - player.setUiMouseDownCallback(null); - } - if (player.setUiMouseUpCallback) { - player.setUiMouseUpCallback(null); - } - try { player._requestPointerLockSafe(); } catch (e) {} - } - // Сообщить редактору/плееру чтобы синхронизировать UI-state - try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {} - } - } catch (e) {} - return; - } - if (cmd === 'app.exit') { - try { - // На Майнкрафтия-плеере это шло на свой роут /kubikon3d - // (лента игр). В выделенном плеере (player.rublox.pro) - // таких роутов нет — переходим на ленту Рублокса. - window.location.assign(this._resolveExternalUrl('/kubikon3d')); - } catch (e) {} - return; - } - if (cmd === 'app.navigate') { - try { - const url = String(payload?.url || ''); - if (url) window.location.assign(this._resolveExternalUrl(url)); - } catch (e) {} - return; - } - // === Универсальное хранилище сейвов (game.save.*) === - if (cmd === 'save.get') { - this._saveGet(scriptId, payload); - return; - } - if (cmd === 'save.getAll') { - this._saveGetAll(scriptId, payload); - return; - } - if (cmd === 'save.set') { - this._saveSet(payload); - return; - } - if (cmd === 'save.merge') { - this._saveMerge(payload); - return; - } - if (cmd === 'save.leaderboard') { - this._saveLeaderboard(scriptId, payload); - return; - } - if (cmd === 'economy.reward') { - this._economyReward(scriptId, payload); - return; - } - if (cmd === 'economy.dailyCheck') { - this._economyDailyCheck(scriptId, payload); - return; - } - if (cmd === 'economy.getBalance') { - this._economyGetBalance(scriptId, payload); - return; - } - if (cmd === 'economy.spend') { - this._economySpend(scriptId, payload); - return; - } - if (cmd === 'camera.shake') { - const player = this.scene3d?.player; - if (player) { - const amp = Number(payload?.amp); - const dur = Number(payload?.dur); - if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) { - player._cameraShakeAmp = amp; - player._cameraShakeLeft = dur; - } - } - return; - } - // === Камера: FOV, привязка, катсцены (Фаза 5.7) === - if (cmd === 'camera.fov') { - this.scene3d?.player?.setCameraFov?.(payload?.degrees); - return; - } - if (cmd === 'camera.focus') { - // payload: { ref, distance, height } — следить за объектом. - const player = this.scene3d?.player; - if (player && payload && typeof payload.ref === 'string') { - const ref = payload.ref; - // getTarget резолвит позицию объекта каждый кадр. - const getTarget = () => { - const tgt = this._resolveTweenTarget(ref); - if (tgt && tgt.data) { - return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z }; - } - return null; - }; - player.cameraFocusOn(getTarget, { - distance: payload.distance, height: payload.height, - }); - } - return; - } - if (cmd === 'camera.cutscene') { - // payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration } - const player = this.scene3d?.player; - if (player && payload && Array.isArray(payload.points)) { - player.cameraCutscene( - payload.points, payload.lookAt, payload.segDuration, - // onDone — событие скрипту. - () => this.routeGlobalEvent('cutsceneDone', {}), - ); - } - return; - } - if (cmd === 'camera.reset') { - this.scene3d?.player?.cameraReset?.(); - return; - } - if (cmd === 'player.setSkinVisible') { - const player = this.scene3d?.player; - if (player) { - const v = !!payload?.visible; - player._skinVisibleScripted = v; - // Применяем сразу — но также флаг будет применяться каждый - // кадр в _tick (на случай если меши ещё не загружены сейчас). - if (Array.isArray(player._modelMeshes)) { - for (const m of player._modelMeshes) { - try { m.setEnabled(v); } catch (e) {} - } - } - } - return; - } - if (cmd === 'player.setCameraMode') { - const player = this.scene3d?.player; - if (player && typeof payload?.mode === 'string') { - const valid = ['first', 'third', 'front', 'sideview']; - if (valid.includes(payload.mode)) { - player._cameraMode = payload.mode; - try { player._applyCameraMode?.(); } catch (e) {} - } - } - return; - } - if (cmd === 'player.setCrouch') { - const player = this.scene3d?.player; - if (player) { - const want = !!payload?.enabled; - player._scriptForcedCrouch = want; - if (want !== player._crouching) { - player._crouching = want; - const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL; - // КРИТИЧНО: _pos — центр капсулы. При смене HALF_H - // центр надо сдвинуть на ту же дельту, иначе «низ ног» - // (_pos.y - HALF_H) меняется и персонажа подкидывает - // вверх при приседе. Сдвигаем — низ ног остаётся на месте. - const dH = newHalfH - player.HALF_H; - player.HALF_H = newHalfH; - if (player._pos) player._pos.y += dH; - } - } - return; - } - if (cmd === 'player.setFacing') { - // Развернуть модель игрока на угол yaw (радианы). Полезно - // в кат-сценах, когда игрок стоит лицом куда нужно. - const player = this.scene3d?.player; - if (player) { - const yaw = Number(payload?.yaw); - if (Number.isFinite(yaw)) { - player._modelYaw = yaw; - if (player._modelRoot) player._modelRoot.rotation.y = yaw; - } - } - return; - } - if (cmd === 'player.emote') { - // Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint). - // Работает только для R15-скинов. - const player = this.scene3d?.player; - if (player && typeof player.playEmote === 'function') { - const name = payload?.name; - if (typeof name === 'string') { - try { player.playEmote(name); } catch (e) { /* ignore */ } - } - } - return; - } - if (cmd === 'player.stopEmote') { - const player = this.scene3d?.player; - if (player && typeof player.stopEmote === 'function') { - try { player.stopEmote(); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') { - // Делегируем в scene3d — у него есть колбэки для UI/API - const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart' - : cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit']; - if (typeof fn === 'function') { - try { fn.call(this.scene3d); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'self.move') { - this._applySelfMove(payload); - return; - } - if (cmd === 'scene.rotate') { - try { - const ry = Number(payload?.rotationY); - if (!Number.isFinite(ry)) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.rotationY = ry; - if (data.mesh?.rotation) { - data.mesh.rotation.y = ry; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у, - // только rotationY обновился, для скрипта это прозрачно. - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.rotate failed', e); - } - return; - } - if (cmd === 'scene.setRotation') { - try { - const rx = Number(payload?.rx); - const ry = Number(payload?.ry); - const rz = Number(payload?.rz); - if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.rotationX = rx; - data.rotationY = ry; - data.rotationZ = rz; - if (data.mesh?.rotation) { - data.mesh.rotation.x = rx; - data.mesh.rotation.y = ry; - data.mesh.rotation.z = rz; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - } catch (e) { - console.warn('[GameRuntime] scene.setRotation failed', e); - } - return; - } - if (cmd === 'scene.setCollide') { - try { - const canCollide = !!payload?.canCollide; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.canCollide = canCollide; - if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide; - this.scene3d?.physics?.setSpatialDirty?.(); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setCollide failed', e); - } - return; - } - if (cmd === 'scene.setColor') { - try { - const color = payload?.color; - if (typeof color !== 'string') return; - // Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager. - const ref = payload?.id; - if (typeof ref === 'string' && ref.startsWith('block:')) { - const parts = ref.slice(6).split(',').map(Number); - if (parts.length === 3 && parts.every(Number.isFinite)) { - this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color); - } - return; - } - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.color = color; - if (data.mesh?.material) { - const c = Color3.FromHexString(color); - data.mesh.material.diffuseColor = c; - // Если материал neon — обновляем emissive тоже - if (data.material === 'neon') { - data.mesh.material.emissiveColor = c; - } - if (data.material === 'studs') { - data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45); - } - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setColor failed', e); - } - return; - } - if (cmd === 'scene.setOpacity') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity }); - } catch (e) { - console.warn('[GameRuntime] scene.setOpacity failed', e); - } - return; - } - if (cmd === 'scene.setScale') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) { - pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz }); - } - } catch (e) { - console.warn('[GameRuntime] scene.setScale failed', e); - } - return; - } - if (cmd === 'scene.setMaterial') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) pm.updateInstance(id, { material: payload.material }); - } catch (e) { - console.warn('[GameRuntime] scene.setMaterial failed', e); - } - return; - } - if (cmd === 'scene.clone') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id == null || !pm) return; - const src = pm.instances.get(id); - if (!src) return; - const newId = pm.addInstance(src.type, { - x: (src.x || 0) + (Number(payload.dx) || 0), - y: (src.y || 0) + (Number(payload.dy) || 0), - z: (src.z || 0) + (Number(payload.dz) || 0), - sx: src.sx, sy: src.sy, sz: src.sz, - color: src.color, material: src.material, - rotationY: src.rotationY, - }); - if (newId != null) { - if (!this._localToReal) this._localToReal = new Map(); - this._localToReal.set(payload.newRef, 'primitive:' + newId); - this.scheduleSceneSnapshot(); - } - } catch (e) { - console.warn('[GameRuntime] scene.clone failed', e); - } - return; - } - if (cmd === 'self.registerInteract') { - try { - const t = payload?.target; - if (!t) return; - // ref объекта-носителя скрипта - const ref = (t.kind && (t.ref ?? t.id) != null) - ? (t.kind + ':' + (t.ref ?? t.id)) : null; - if (!ref) return; - // не дублируем — один объект = одна запись - if (!this._interactables.some(it => it.ref === ref)) { - this._interactables.push({ - ref, - target: t, - text: payload.text || 'Взаимодействовать', - distance: Number(payload.distance) || 4, - key: payload.key || 'e', - }); - } - } catch (e) { - console.warn('[GameRuntime] self.registerInteract failed', e); - } - return; - } - if (cmd === 'scene.setLabel') { - try { - const ref = payload?.ref; - const text = payload?.text; - if (typeof ref !== 'string') return; - // ленивое создание менеджера меток - if (!this.scene3d._labelManager) { - const { LabelManager } = require('./LabelManager'); - this.scene3d._labelManager = new LabelManager(this.scene3d.scene); - } - const lm = this.scene3d._labelManager; - // резолвим меш объекта (примитив или модель) - const tgt = this._resolveTweenTarget(ref); - const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); - if (mesh) { - lm.setLabel(ref, mesh, text, payload?.opts || {}); - } - } catch (e) { - console.warn('[GameRuntime] scene.setLabel failed', e); - } - return; - } - if (cmd === 'scene.clearLabel') { - try { - const lm = this.scene3d?._labelManager; - if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref); - } catch (e) { - console.warn('[GameRuntime] scene.clearLabel failed', e); - } - return; - } - if (cmd === 'scene.setData') { - try { - const { ref, key, value } = payload || {}; - if (typeof ref !== 'string' || typeof key !== 'string') return; - if (!this._objectData[ref]) this._objectData[ref] = {}; - this._objectData[ref][key] = value; - this.scheduleDataSnapshot(); - } catch (e) { - console.warn('[GameRuntime] scene.setData failed', e); - } - return; - } - // === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged === - // Теги хранятся как массив в _objectData[ref].__tags — переиспользуем - // готовый канал dataSnapshot, отдельная синхронизация не нужна. - if (cmd === 'scene.tag' || cmd === 'scene.untag') { - try { - const { ref, tag } = payload || {}; - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - if (!this._objectData[ref]) this._objectData[ref] = {}; - const cur = Array.isArray(this._objectData[ref].__tags) - ? this._objectData[ref].__tags : []; - this._objectData[ref].__tags = cmd === 'scene.tag' - ? (cur.includes(tag) ? cur : [...cur, tag]) - : cur.filter(t => t !== tag); - this.scheduleDataSnapshot(); - } catch (e) { - console.warn('[GameRuntime] scene.tag failed', e); - } - return; - } - // === Collision groups (Фаза 5.9) — проходимость объекта/группы === - // physics.passThrough — игрок проходит сквозь объект (объект виден). - // target: ref одного объекта ИЛИ тег (тогда применяется ко всей - // группе объектов с этим тегом — теги = collision groups). - if (cmd === 'physics.passThrough') { - try { - const { target, on } = payload || {}; - if (typeof target !== 'string' || !target) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - // canCollide = !on (passThrough=true → коллизия выключена). - const canCollide = !on; - // Собираем список ref: либо один объект, либо все с тегом. - let refs; - if (target.indexOf(':') >= 0) { - refs = [target]; // похоже на ref объекта - } else { - // Тег — все объекты с ним. - refs = []; - for (const r of Object.keys(this._objectData)) { - const bag = this._objectData[r]; - if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) { - refs.push(r); - } - } - } - for (const r of refs) { - const rid = this._resolvePrimitiveId(r); - if (rid != null) pm.updateInstance(rid, { canCollide }); - } - // Сбрасываем кэш spatial-grid физики — иначе grid до 50мс - // держит старое состояние, и при возврате твёрдости (on=false) - // UNSTUCK не видит стену, игрок застревает в ней. - this.scene3d?.physics?.invalidateSpatialGrid?.(); - } catch (e) { - console.warn('[GameRuntime] physics.passThrough failed', e); - } - return; - } - if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - const dm = this.scene3d?.dynamics; - if (id == null || !pm || !dm) return; - const data = pm.instances.get(id); - if (!data) return; - const isImpulse = cmd === 'physics.applyImpulse'; - const vx = isImpulse ? payload.ix : payload.vx; - const vy = isImpulse ? payload.iy : payload.vy; - const vz = isImpulse ? payload.iz : payload.vz; - const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set'); - if (!ok) { - this._log('error', cmd + ': объект закреплён (anchored) — ' - + 'физика работает только для незакреплённых объектов'); - } - } catch (e) { - console.warn('[GameRuntime] ' + cmd + ' failed', e); - } - return; - } - if (cmd === 'physics.explode') { - try { - const { x, y, z, radius, damage, force } = payload || {}; - const r = Number(radius) || 3; - // визуальный эффект взрыва - this._handleCommand(scriptId, 'scene.particles', { - type: 'explosion', position: { x, y, z }, - duration: 1.2, count: 2, color: null, - }); - // урон игроку если в радиусе - const player = this.scene3d?.player; - if (player && Number(damage) > 0) { - const pp = player._pos || player.position; - if (pp) { - const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z; - if (dx*dx + dy*dy + dz*dz <= r*r) { - try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {} - } - } - } - // убиваем мобов в радиусе - const zm = this.scene3d?.zombieManager; - if (zm && typeof zm.getMobsSnapshot === 'function') { - const mobs = zm.getMobsSnapshot(); - for (const m of mobs) { - const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z; - if (dx*dx + dy*dy + dz*dz <= r*r) { - try { zm.killById(m.id); } catch (e) {} - } - } - } - } catch (e) { - console.warn('[GameRuntime] physics.explode failed', e); - } - return; - } - if (cmd === 'tween.start') { - this._startTween(scriptId, payload); - return; - } - if (cmd === 'tween.cancel') { - const tid = payload?.tweenId; - if (tid != null) { - const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId); - if (i >= 0) this._tweens.splice(i, 1); - } - return; - } - if (cmd === 'scene.setTexture') { - // Установить динамическую текстуру примитива из dataURL. - // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). - try { - const dataUrl = payload?.dataUrl; - if (typeof dataUrl !== 'string') return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - if (rid != null) pm.setTexture(rid, dataUrl); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setTexture failed', e); - } - return; - } - // === AUDIO: GD-музыка и SFX === - if (cmd === 'audio.playSfx') { - try { - const am = this.scene3d?.gameAudioManager; - if (am && payload?.name) am.playSfx(payload.name); - } catch (e) { - console.warn('[GameRuntime] audio.playSfx failed', e); - } - return; - } - if (cmd === 'audio.playMusic') { - try { - const am = this.scene3d?.gameAudioManager; - if (am && payload?.trackId) am.playMusic(payload.trackId); - } catch (e) { - console.warn('[GameRuntime] audio.playMusic failed', e); - } - return; - } - if (cmd === 'audio.stopMusic') { - try { - const am = this.scene3d?.gameAudioManager; - if (am) am.stopMusic(); - } catch (e) { - console.warn('[GameRuntime] audio.stopMusic failed', e); - } - return; - } - if (cmd === 'audio.setMuted') { - try { - const am = this.scene3d?.gameAudioManager; - if (am) am.setMuted(!!payload?.muted); - } catch (e) { - console.warn('[GameRuntime] audio.setMuted failed', e); - } - return; - } - if (cmd === 'scene.setVisible') { - try { - const kind = payload?.kind; - const id = payload?.id; - const visible = !!payload?.visible; - if (id == null) return; - if (kind === 'primitive') { - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.visible = visible; - if (data.mesh) data.mesh.setEnabled(visible); - } - } else if (kind === 'model') { - const mm = this.scene3d?.modelManager; - if (!mm) return; - let data = mm.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = mm.instances.get(n); - } - if (data) { - data.visible = visible; - if (data.rootMesh) data.rootMesh.setEnabled(visible); - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setVisible failed', e); - } - return; - } - if (cmd === 'scene.setFolderYaw') { - try { - const fm = this.scene3d?.folderManager; - if (!fm) return; - const name = payload?.folderName; - const angle = Number(payload?.angle); - const pivot = payload?.pivot; - if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return; - const folder = fm.findByName(name); - if (!folder) return; - fm.setFolderYawY(folder.id, angle, pivot); - this.scheduleSceneSnapshot(); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setFolderYaw failed', e); - } - return; - } - if (cmd === 'self.delete') { - this._applySelfDelete(payload); - return; - } - if (cmd === 'scene.spawn') { - this._applySceneSpawn(scriptId, payload); - return; - } - if (cmd === 'scene.delete') { - this._applySceneDelete(payload); - return; - } - if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') { - // Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует - if (this._onHud) { - try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'sound.play') { - this._playSound(payload); - return; - } - if (cmd === 'scene.particles') { - this._spawnParticles(payload); - return; - } - if (cmd === 'mob.kill') { - try { - const id = Number(payload?.id); - if (Number.isFinite(id) && this.scene3d?.zombieManager) { - this.scene3d.zombieManager.killById(id); - } - } catch (e) { - this._log('error', 'mob.kill failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.update') { - // payload: { id, patch } - try { - let id = payload?.id; - const patch = payload?.patch || {}; - if (typeof id !== 'string') return; - // Резолвим локальный ref (тот что вернул gui.create) → реальный id - if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); - this.scene3d?.updateGuiElement?.(id, patch); - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.update failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.create') { - try { - const type = payload?.type; - const opts = { ...(payload?.opts || {}) }; - const localRef = payload?.localRef; - if (typeof type !== 'string') return; - // Помечаем как созданный скриптом — чтобы НЕ попал в - // сериализацию проекта (иначе автосейв сохранит его в БД - // и после Stop он «вернётся» из сохранённого проекта). - opts._scriptCreated = true; - // Резолвим parentId если это локальный ref из предыдущего create - if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) { - opts.parentId = this._guiLocalToReal.get(opts.parentId); - } - const realId = this.scene3d?.createGuiElement?.(type, opts); - if (realId && localRef) { - if (!this._guiLocalToReal) this._guiLocalToReal = new Map(); - if (!this._guiRealToLocal) this._guiRealToLocal = new Map(); - this._guiLocalToReal.set(localRef, realId); - this._guiRealToLocal.set(realId, localRef); - } - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.create failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.remove') { - try { - let id = payload?.id; - if (typeof id !== 'string') return; - const localId = id; - if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); - this.scene3d?.removeGuiElement?.(id); - // Чистим mapping чтобы не утекало - if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId); - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.remove failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'broadcast') { - // Рассылаем именованное сообщение всем sandbox'ам - this.routeGlobalEvent('message', { - name: String(payload?.name || ''), - data: payload?.data ?? null, - }); - return; - } - if (cmd === 'player.crosshair') { - const type = String(payload?.type || 'none').toLowerCase(); - try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ } - if (this._onCrosshair) { - try { this._onCrosshair(type); } catch (e) { /* ignore */ } - } - return; - } - // === Задача 07: скины игрока === - if (cmd === 'player.setSkin') { - const player = this.scene3d?.player; - const slug = payload?.slug; - if (player && typeof slug === 'string' && slug) { - const typeId = this._resolveSkinTypeId(slug); - // Помечаем доступным (setSkin неявно разблокирует). - this._ensureSkinState(); - this._skinState.unlocked.add(slug); - this._skinState.current = slug; - // Асинхронная перезагрузка модели; по завершении шлём skinChanged. - Promise.resolve(player.reloadSkin?.(typeId)).then(() => { - this.routeGlobalEvent?.('skinChanged', { slug }); - try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {} - }).catch((e) => { - this._log('error', 'setSkin failed: ' + (e?.message || e)); - }); - } - return; - } - if (cmd === 'player.unlockSkin') { - const slug = payload?.slug; - if (typeof slug === 'string' && slug) { - this._ensureSkinState(); - this._skinState.unlocked.add(slug); - this.routeGlobalEvent?.('skinUnlocked', { slug }); - } - return; - } - if (cmd === 'player.openSkinShop') { - this._ensureSkinState(); - try { this.scene3d?._openSkinShop?.(); } catch (e) {} - return; - } - if (cmd === 'player.closeSkinShop') { - try { this.scene3d?._closeSkinShop?.(); } catch (e) {} - return; - } - if (cmd === 'player.setSkinCoins') { - this._ensureSkinState(); - const n = Number(payload?.amount); - if (Number.isFinite(n)) { - this._skinState.coins = Math.max(0, Math.floor(n)); - this._broadcastSkinsSnapshot(); - } - return; - } - // Покупка скина из встроенного магазина (намерение от React-оверлея - // или из скрипта). Списывает локальные рублики, разблокирует, надевает. - if (cmd === 'player.buySkin') { - this._ensureSkinState(); - const slug = payload?.slug; - const price = Number(payload?.price) || 0; - if (typeof slug !== 'string' || !slug) return; - const st = this._skinState; - const owned = st.unlocked.has(slug); - if (owned) { - // Уже куплен — просто надеть. - this._handleCommand(scriptId, 'player.setSkin', { slug }); - return; - } - if (st.coins < price) { - // Не хватает — сообщаем оверлею. - try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {} - return; - } - st.coins -= price; - st.unlocked.add(slug); - this._handleCommand(scriptId, 'player.setSkin', { slug }); - this._broadcastSkinsSnapshot(); - try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {} - return; - } - // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock === - if (cmd === 'player.setCameraZoom') { - const player = this.scene3d?.player; - if (player && typeof player.setCameraZoom === 'function') { - try { player.setCameraZoom(payload?.distance); } catch (e) {} - } - return; - } - if (cmd === 'player.setCameraZoomLimits') { - const player = this.scene3d?.player; - if (player && typeof player.setCameraZoomLimits === 'function') { - try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {} - } - return; - } - if (cmd === 'player.setShiftLock') { - const player = this.scene3d?.player; - if (player && typeof player.setShiftLock === 'function') { - try { player.setShiftLock(payload?.on); } catch (e) {} - } - return; - } - // === Задача 02: environment API === - if (cmd === 'environment.setSkyColor') { - try { - const hex = String(payload?.color || ''); - const scene = this.scene3d?.scene; - if (scene && hex) { - // Парсим #rrggbb → clearColor - const m = hex.match(/^#?([0-9a-f]{6})$/i); - if (m) { - const n = parseInt(m[1], 16); - const r = ((n >> 16) & 0xff) / 255; - const g = ((n >> 8) & 0xff) / 255; - const b = (n & 0xff) / 255; - if (scene.clearColor) { - scene.clearColor.r = r; - scene.clearColor.g = g; - scene.clearColor.b = b; - scene.clearColor.a = 1; - } - } - } - } catch (e) { - this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'environment.setFog') { - try { - const env = this.scene3d?.environment; - if (env && typeof env.setFog === 'function') { - env.setFog(payload?.enabled, payload?.color, payload?.density); - } - } catch (e) {} - return; - } - if (cmd === 'environment.setTimeOfDay') { - try { - const env = this.scene3d?.environment; - if (env && typeof env.setTimeOfDay === 'function') { - env.setTimeOfDay(payload?.hours); - } - } catch (e) {} - return; - } - // === Задача 03: GUI tween === - if (cmd === 'gui.tween') { - try { - const guiId = payload?.id; - if (typeof guiId !== 'string' || !guiId) return; - const gm = this.scene3d?.guiManager; - if (!gm) return; - // Резолв localRef → realId если есть - let realId = guiId; - if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId); - const el = gm.elements?.find(e => e.id === realId); - if (!el) return; - if (!this._guiTweens) this._guiTweens = []; - // Снимок начальных значений по тем ключам что есть в props - const props = payload.props || {}; - const propKeys = Object.keys(props); - // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id, - // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей. - // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый. - for (let j = this._guiTweens.length - 1; j >= 0; j--) { - const old = this._guiTweens[j]; - if (old.realId !== realId) continue; - const oldKeys = Object.keys(old.target); - const overlap = oldKeys.some(k => propKeys.includes(k)); - if (overlap) this._guiTweens.splice(j, 1); - } - const start = {}; - for (const k of propKeys) { - if (k in el) start[k] = el[k]; - else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1); - } - this._guiTweens.push({ - tweenId: payload.tweenId, - scriptId, - realId, - start, target: { ...props }, - elapsed: 0, - duration: Math.max(0.001, Number(payload.duration) || 0.5), - delay: Math.max(0, Number(payload.delay) || 0), - easing: payload.easing || 'ease', - repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0, - reverses: !!payload.reverses, - iter: 0, - dir: 1, // 1 = вперёд, -1 = обратно (для reverses) - }); - } catch (e) { - this._log('error', 'gui.tween failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.cancelTween') { - const tid = payload?.tweenId; - if (tid != null && this._guiTweens) { - const i = this._guiTweens.findIndex(t => t.tweenId === tid); - if (i >= 0) this._guiTweens.splice(i, 1); - } - return; - } - // === Задача 04: модал-сцены === - if (cmd === 'modal.open') { - try { - const mm = this.scene3d?.modalManager; - if (!mm) return; - // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно - const opts = { ...(payload?.opts || {}) }; - if (Array.isArray(opts.spotlights)) { - opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r); - } - if (opts.cameraOverride && opts.cameraOverride.target) { - opts.cameraOverride = { - ...opts.cameraOverride, - target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target, - }; - } - const modalId = mm.open(opts); - // Подписка чтобы автоматически слать tweenDone-стиль событий - // на конкретный скрипт (тот кто открыл) — для onClose. - if (!mm._runtimeBoundOnClose) { - mm._runtimeBoundOnClose = true; - mm.onClose((closedId) => { - // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn - this.routeGlobalEvent?.('modalClosed', { id: closedId }); - }); - } - // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open) - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && payload?.replyId != null) { - sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId }); - } - } catch (e) { - this._log('error', 'modal.open failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'modal.close') { - try { - const mm = this.scene3d?.modalManager; - mm?.close?.(payload?.modalId); - } catch (e) {} - return; - } - if (cmd === 'modal.update') { - try { - const mm = this.scene3d?.modalManager; - mm?.update?.(payload?.modalId, payload?.patch); - } catch (e) {} - return; - } - // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) === - if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { - // Резолв ref → primitiveId. - // Worker может прислать ref сразу после game.scene.spawn — до - // того как main spawn'нул примитив и обновил _localToReal. - // Откладываем команду до резолва. - let ref = payload?.ref; - if (typeof ref === 'string' && ref.includes('_local_') - && !this._localToReal?.has(ref)) { - this._pendingResolveQueue = this._pendingResolveQueue || []; - this._pendingResolveQueue.push({ cmd, payload, scriptId }); - return; - } - try { - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - let id = null; - if (typeof ref === 'string' && ref.startsWith('primitive:')) { - id = Number(ref.slice('primitive:'.length)); - } else if (Number.isFinite(ref)) { - id = Number(ref); - } - if (!Number.isFinite(id) || id == null) return; - const data = this.scene3d?.primitiveManager?.instances?.get(id); - if (!data || data.type !== 'billboard') return; - const mgr = this.scene3d?.billboardUiManager; - if (!mgr) return; - - if (cmd === 'billboard.set') { - mgr.applyToMesh(data, { - template: payload.template || data.billboard?.template || 'shop-item', - face: payload.face || data.billboard?.face || 'camera', - content: payload.content || data.billboard?.content, - elements: payload.elements || data.billboard?.elements, - }); - this.scheduleSceneSnapshot?.(); - } else if (cmd === 'billboard.update') { - // 2 формы: с elementId (точечно) или без (patch content) - if (typeof payload.elementId === 'string') { - mgr.update(data, payload.elementId, payload.patch || {}); - } else { - mgr.update(data, payload.patch || {}); - } - this.scheduleSceneSnapshot?.(); - } else if (cmd === 'billboard.onClick') { - const buttonId = String(payload.buttonId || 'buy'); - const realRef = 'primitive:' + id; - mgr.onClick(data, buttonId, () => { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && typeof sb.sendGlobalEvent === 'function') { - // billboardClick роутится в worker'е через globalEvent-ветку - // (см. ScriptSandboxWorker.js cmd === 'globalEvent'). - sb.sendGlobalEvent({ - type: 'billboardClick', - ref: realRef, - button: buttonId, - }); - } - }); - } - } catch (e) { - this._log('error', cmd + ' failed: ' + (e?.message || e)); - } - return; - } - // eslint-disable-next-line no-console - console.warn('[GameRuntime] unknown cmd', cmd); - } - - /** - * Создать объект из скрипта. - * payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... } - * После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id. - */ - _applySceneSpawn(scriptId, payload) { - if (!payload) return; - const { kind, subType, ref } = payload; - if (!this._localToReal) this._localToReal = new Map(); - try { - if (kind === 'block') { - // color — для окрашиваемых блоков (studs-block); иначе игнорируется. - this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color); - // Для блоков ref детерминированный, но запоминаем — чтобы при - // Stop удалить заспавненные скриптом блоки (см. stop()). - if (ref) this._localToReal.set(ref, ref); - this.scheduleSceneSnapshot(); - } else if (kind === 'model') { - // addInstance возвращает Promise (async из-за GLB) - const opts = payload; - const p = this.scene3d?.modelManager?.addInstance( - subType, opts.x, opts.y, opts.z, opts.rotationY || 0 - ); - Promise.resolve(p).then((instId) => { - if (instId == null) return; - if (opts.name) { - const data = this.scene3d?.modelManager?.instances?.get(instId); - if (data) data.name = opts.name; - } - this._localToReal.set(ref, 'model:' + instId); - this._notifySpawnResolved(ref, 'model:' + instId); - this._drainPendingResolveQueue?.(ref); - this.scheduleSceneSnapshot(); - }).catch((err) => { - this._log('error', 'spawn model failed: ' + (err?.message || err)); - }); - } else if (kind === 'userModel') { - // Пользовательская воксельная модель: subType = 'user:'. - // addInstance возвращает Promise. - const opts = payload; - const p = this.scene3d?.userModelManager?.addInstance( - subType, opts.x, opts.y, opts.z, opts.rotationY || 0, - ); - Promise.resolve(p).then((instId) => { - if (instId == null) return; - if (opts.name) { - const data = this.scene3d?.userModelManager?.instances?.get(instId); - if (data) data.name = opts.name; - } - this._localToReal.set(ref, 'usermodel:' + instId); - this._notifySpawnResolved(ref, 'usermodel:' + instId); - this._drainPendingResolveQueue?.(ref); - this.scheduleSceneSnapshot(); - }).catch((err) => { - this._log('error', 'spawn user model failed: ' + (err?.message || err)); - }); - } else if (kind === 'primitive') { - const opts = payload; - const id = this.scene3d?.primitiveManager?.addInstance(subType, { - x: opts.x, y: opts.y, z: opts.z, - sx: opts.sx, sy: opts.sy, sz: opts.sz, - color: opts.color, material: opts.material, - rotationY: opts.rotationY, - name: opts.name, - brightness: opts.brightness, range: opts.range, - effect: opts.effect, - // textureAsset — картинка из ассетов проекта на грани. - ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}), - // anchored:false → объект падает (физика unanchored). - // canCollide:false → проходимый (зона-триггер). - ...(opts.anchored != null ? { anchored: opts.anchored } : {}), - ...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}), - ...(opts.visible != null ? { visible: opts.visible } : {}), - }); - if (id != null) { - this._localToReal.set(ref, 'primitive:' + id); - this._notifySpawnResolved(ref, 'primitive:' + id); - this._drainPendingResolveQueue?.(ref); - const data = this.scene3d?.primitiveManager?.instances?.get(id); - if (data) { - // Помечаем как заспавненный скриптом — движок шлёт - // для таких onPlayerTouch (нужно для «поймай объект»). - data._scriptSpawned = true; - // Если unanchored — регистрируем в физике на лету, - // иначе он не падает (start() уже отработал). - if (opts.anchored === false) { - this.scene3d?.dynamics?.registerPrimitive(data); - } - } - this.scheduleSceneSnapshot(); - } - } - } catch (e) { - this._log('error', 'scene.spawn failed: ' + (e?.message || e)); - } - } - - /** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */ - _applySceneDelete(payload) { - if (!payload?.ref) return; - let ref = payload.ref; - // Резолвим локальный ref → реальный - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - // Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась - // (асинхронная загрузка GLB). Откладываем удаление: оно сработает - // в _notifySpawnResolved, когда реальный id появится. Без этого - // removeInstance(NaN) промахивался и объект «осиротевал» на сцене. - if (ref.indexOf('_local_') >= 0) { - if (!this._pendingDeletes) this._pendingDeletes = new Set(); - this._pendingDeletes.add(ref); - return; - } - try { - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const rest = ref.slice(colon + 1); - if (kind === 'block') { - const [xs, ys, zs] = rest.split(','); - this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); - } else if (kind === 'model') { - this.scene3d?.modelManager?.removeInstance(Number(rest)); - } else if (kind === 'primitive') { - this.scene3d?.primitiveManager?.removeInstance(Number(rest)); - } - // Удалили — снимаем mapping - for (const [k, v] of (this._localToReal || new Map()).entries()) { - if (v === ref) this._localToReal.delete(k); - } - this.scheduleSceneSnapshot(); - } catch (e) { - this._log('error', 'scene.delete failed: ' + (e?.message || e)); - } - } - - /** - * Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре. - * Делается отложенно чтобы при массовом spawn (например в onKey) отправить - * snapshot один раз, а не N раз. - */ - scheduleSceneSnapshot() { - if (this._snapshotPending) return; - this._snapshotPending = true; - // microtask — следующий кадр render-loop'а почти наверняка - Promise.resolve().then(() => { - this._snapshotPending = false; - this._broadcastSceneSnapshot(); - }); - } - - /** Рассылка snapshot всем sandbox'ам. */ - _broadcastSceneSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - const snap = this._buildSceneSnapshot(); - for (const sb of this.sandboxes) { - sb.sendSceneSnapshot(snap); - } - } - - /** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */ - scheduleGuiSnapshot() { - if (this._guiSnapshotPending) return; - this._guiSnapshotPending = true; - Promise.resolve().then(() => { - this._guiSnapshotPending = false; - this._broadcastGuiSnapshot(); - }); - } - - _broadcastGuiSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - const snap = this._buildGuiSnapshot(); - for (const sb of this.sandboxes) { - sb.sendGuiSnapshot(snap); - } - } - - /** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */ - scheduleDataSnapshot() { - if (this._dataSnapshotPending) return; - this._dataSnapshotPending = true; - Promise.resolve().then(() => { - this._dataSnapshotPending = false; - this._broadcastDataSnapshot(); - }); - } - - _broadcastDataSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - for (const sb of this.sandboxes) { - sb.sendDataSnapshot(this._objectData); - } - } - - _buildGuiSnapshot() { - const list = this.scene3d?.getGuiElements?.() || []; - return list.map(g => ({ - id: g.id, type: g.type, name: g.name, - parentId: g.parentId || null, - x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor, - visible: g.visible !== false, - text: g.text, textColor: g.textColor, textSize: g.textSize, - bgColor: g.bgColor, bgOpacity: g.bgOpacity, - imageUrl: g.imageUrl, - placeholder: g.placeholder, - })); - } - - /** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */ - _buildSceneSnapshot() { - const blocks = []; - const models = []; - const primitives = []; - const s = this.scene3d; - if (s?.blockManager) { - for (const proxy of s.blockManager.blocks.values()) { - const md = proxy.metadata; - if (!md?.isBlock) continue; - blocks.push({ - ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ, - type: md.blockTypeId, - x: md.gridX, y: md.gridY, z: md.gridZ, - }); - } - } - if (s?.modelManager) { - for (const data of s.modelManager.instances.values()) { - models.push({ - ref: 'model:' + data.instanceId, - type: data.modelTypeId, - x: data.x, y: data.y, z: data.z, - name: data.name || null, - }); - } - } - if (s?.primitiveManager) { - for (const data of s.primitiveManager.instances.values()) { - primitives.push({ - ref: 'primitive:' + data.id, - type: data.type, - x: data.x, y: data.y, z: data.z, - // размеры/поворот нужны для game.physics.raycast (ray vs AABB) - sx: data.sx != null ? data.sx : 1, - sy: data.sy != null ? data.sy : 1, - sz: data.sz != null ? data.sz : 1, - rotationY: data.rotationY || 0, - visible: data.visible !== false, - name: data.name || null, - }); - } - } - return { blocks, models, primitives }; - } - - _applySelfMove(payload) { - if (!payload || !payload.target) return; - const t = payload.target; - const { x, y, z } = payload; - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; - try { - if (t.kind === 'model') { - let id = t.id ?? t.ref; - const mm = this.scene3d?.modelManager; - if (!mm) return; - // Локальный ref '_local_N' от scene.spawn → реальный id. - if (typeof id === 'string' && id.indexOf('_local_') === 0 - && this._localToReal) { - const real = this._localToReal.get('model:' + id); - if (real) { - const c2 = real.indexOf(':'); - id = c2 >= 0 ? real.slice(c2 + 1) : real; - } - } - let data = mm.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = mm.instances.get(n); - } - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.rootMesh?.position) { - data.rootMesh.position.set(x, y, z); - if (data._worldMatrixFrozen) { - try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {} - if (Array.isArray(data.meshes)) { - for (const m of data.meshes) { - try { m?.unfreezeWorldMatrix?.(); } catch (e) {} - } - } - data._worldMatrixFrozen = false; - } - } - } - } else if (t.kind === 'primitive') { - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - // _resolvePrimitiveId умеет и числовой id, и локальный - // ref '_local_N' (от scene.spawn) — без этого scene.move - // не находит объект, заспавненный скриптом. - const rid = this._resolvePrimitiveId(t.id ?? t.ref); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.mesh?.position) { - data.mesh.position.set(x, y, z); - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - } else if (t.kind === 'userModel') { - // userModel-инстанс: отдельная нода (rootNode), не thin-instance. - // Двигаем root.position + обновляем data.x/y/z. - const id = t.id ?? t.ref; - const um = this.scene3d?.userModelManager; - if (!um) return; - let data = um.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = um.instances.get(n); - } - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.rootNode?.position) { - data.rootNode.position.set(x, y, z); - } - } - } - // НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё - // равно стейл (sandbox использует findOne и сам не зависит от - // координат в snapshot). Иначе при анимации платформ (десятки - // scene.move в секунду) шлём весь snapshot 11000+ объектов в worker - // через структурный postMessage — это может стоить сотни мс. - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] self.move failed', e); - } - } - - _applySelfDelete(payload) { - if (!payload || !payload.target) return; - const t = payload.target; - try { - if (t.kind === 'block') { - const r = t.ref || t; - this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z); - } else if (t.kind === 'model') { - const id = t.id ?? t.ref; - this.scene3d?.modelManager?.removeInstance(id); - } else if (t.kind === 'primitive') { - const id = t.id ?? t.ref; - this.scene3d?.primitiveManager?.removeInstance(id); - } - this.scheduleSceneSnapshot(); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] self.delete failed', e); - } - } - - _log(level, text) { - if (this._onLog) { - try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } - } - } - - /** - * Воспроизвести встроенный звуковой эффект через Web Audio API. - * Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная. - * Поддерживаемые: jump, pickup, win, lose, click, hit, coin. - */ - _playSound(payload) { - if (!payload || typeof payload.name !== 'string') return; - const name = payload.name; - const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1; - const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1; - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const t = ctx.currentTime; - // Описание звуков: одна или несколько oscillator-волн с envelope - switch (name) { - case 'jump': this._sfxJump(ctx, t, volume, pitch); break; - case 'pickup': this._sfxPickup(ctx, t, volume, pitch); break; - case 'win': this._sfxWin(ctx, t, volume, pitch); break; - case 'lose': this._sfxLose(ctx, t, volume, pitch); break; - case 'click': this._sfxClick(ctx, t, volume, pitch); break; - case 'hit': this._sfxHit(ctx, t, volume, pitch); break; - case 'coin': this._sfxCoin(ctx, t, volume, pitch); break; - default: - this._log('warn', `Неизвестный звук: ${name}`); - } - } catch (e) { - // ignore - } - } - - // === Звуковые пресеты (Web Audio) === - _sfxOsc(ctx, t, type, freq0, freq1, dur, vol) { - const osc = ctx.createOscillator(); - osc.type = type; - osc.frequency.setValueAtTime(freq0, t); - if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur); - const g = ctx.createGain(); - g.gain.setValueAtTime(0, t); - g.gain.linearRampToValueAtTime(vol, t + 0.005); - g.gain.exponentialRampToValueAtTime(0.001, t + dur); - osc.connect(g).connect(ctx.destination); - osc.start(t); - osc.stop(t + dur + 0.02); - } - _sfxJump(ctx, t, vol, pitch) { - // Похож на встроенный звук прыжка PlayerController. - this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol); - this._sfxOsc(ctx, t, 'sine', 110 * pitch, 60 * pitch, 0.07, 0.35 * vol); - } - _sfxPickup(ctx, t, vol, pitch) { - // Восходящие два тона — «пик-апнул!» - this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol); - this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol); - } - _sfxCoin(ctx, t, vol, pitch) { - // Классический «динь-динь» - this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol); - this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol); - } - _sfxWin(ctx, t, vol, pitch) { - // Мажорный аккорд C-E-G по очереди - const notes = [523, 659, 784]; - notes.forEach((f, i) => { - this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol); - }); - } - _sfxLose(ctx, t, vol, pitch) { - // Нисходящий «провал» - this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol); - this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol); - } - _sfxClick(ctx, t, vol, pitch) { - // Короткий «тик» - this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol); - } - /** - * Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек. - * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. - */ - _spawnParticles(payload) { - if (!payload || !this.scene3d?._spawnParticleEffect) return; - try { - this.scene3d._spawnParticleEffect(payload); - } catch (e) { - this._log('error', 'spawnParticles failed: ' + (e?.message || e)); - } - } - - _sfxHit(ctx, t, vol, pitch) { - // Глухой «тук»: низкий sine + шумовой burst - this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol); - // Шум через короткий buffer-noise - const bufLen = Math.floor(ctx.sampleRate * 0.06); - const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen); - const src = ctx.createBufferSource(); - src.buffer = buf; - const lp = ctx.createBiquadFilter(); - lp.type = 'lowpass'; - lp.frequency.value = 1000 * pitch; - const g = ctx.createGain(); - g.gain.value = 0.18 * vol; - src.connect(lp).connect(g).connect(ctx.destination); - src.start(t); - } - - // === Универсальное хранилище сейвов (game.save.*) === - _saveProjectId() { - return this.scene3d?._currentProjectId || this.scene3d?.projectId || null; - } - _saveBaseUrl(namespace) { - const pid = this._saveProjectId(); - const uid = this.scene3d?._currentUserId; - if (!pid || !uid) return null; - const ns = encodeURIComponent(namespace || 'default'); - return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`; - } - _saveReply(scriptId, reqId, result) { - for (const sb of this.sandboxes) { - if (sb.scriptId === scriptId) { - try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {} - return; - } - } - } - _saveGet(scriptId, payload) { - const reqId = payload?.reqId; - const url = this._saveBaseUrl(payload?.namespace); - if (!url) { this._saveReply(scriptId, reqId, null); return; } - // GET savegame теперь тоже требует JWT (бэк ужесточили после - // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же - // headers что _saveSet/_saveMerge. - const headers = {}; - try { - const t = localStorage.getItem('Authorization'); - if (t) headers.Authorization = t; - } catch (e) {} - fetch(url, { headers }).then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.data ?? null)) - .catch(() => this._saveReply(scriptId, reqId, null)); - } - _saveGetAll(scriptId, payload) { - const reqId = payload?.reqId; - const pid = this._saveProjectId(); - const uid = this.scene3d?._currentUserId; - if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; } - const headers = {}; - try { - const t = localStorage.getItem('Authorization'); - if (t) headers.Authorization = t; - } catch (e) {} - fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers }) - .then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) - .catch(() => this._saveReply(scriptId, reqId, {})); - } - // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...) - // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере - // (player.rublox.pro) этих SPA-роутов нет. - // - // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии - // на mnk, разные домены/localStorage; получался 401): - // - http(s)://... → как есть (уже абсолютный) - // - /kubikon/gd* → rublox.pro/app/gd (порт меню GD) - // - /kubikon/play/N → ticket-flow в плеер уже идёт; - // сюда попасть можно только через - // app.navigate из скрипта уровня - // (Например 'играть ещё раз' → - // /kubikon/play/296?play=1&t=ts). - // Парсим id и шлём прямо в плеер. - // - /kubikon*, /kubikon3d* → rublox.pro/app (лента игр) - // - /app, /app/* → rublox.pro - // - всё остальное → rublox.pro/app (фоллбек) - // - // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro. - _resolveExternalUrl(url) { - try { - // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app). - // На dev rublox-site обычно крутится на :3004 — можно переопределить. - const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; - const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, ''); // .../app → ... - - if (!url) return RUBLOX_HOME; - if (/^https?:\/\//i.test(url)) return url; - - // /kubikon/play/ — рестарт уровня. Перезагружаем плеер сам. - const playMatch = url.match(/^\/kubikon\/play\/(\d+)/); - if (playMatch) { - const playerBase = typeof window !== 'undefined' - ? `${window.location.protocol}//${window.location.host}` - : ''; - return `${playerBase}/${playMatch[1]}`; - } - // Legacy /kubikon/* роуты — редирект на главный сайт. - if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd'; - if (url.startsWith('/kubikon')) return rubloxBase + '/app'; - if (url.startsWith('/app')) return rubloxBase + url; - return rubloxBase + '/app'; - } catch (e) { - const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; - } - } - // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401). - // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка - // (этот же баг там тоже есть — сохранения GD-прогресса не работали - // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT - // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js - // saveJWT — он кладёт JWT в оба ключа). - _saveAuthHeaders() { - const h = { 'Content-Type': 'application/json' }; - try { - const t = localStorage.getItem('Authorization'); - if (t) h.Authorization = t; - } catch (e) {} - return h; - } - _saveSet(payload) { - const url = this._saveBaseUrl(payload?.namespace); - if (!url) return; - try { - fetch(url, { - method: 'POST', - headers: this._saveAuthHeaders(), - body: JSON.stringify({ data: payload.data }), - }).catch(() => {}); - } catch (e) {} - } - _saveMerge(payload) { - const url = this._saveBaseUrl(payload?.namespace); - if (!url) return; - try { - fetch(url + '/merge', { - method: 'POST', - headers: this._saveAuthHeaders(), - body: JSON.stringify({ - patch: payload.patch || {}, - increment: payload.increment || {}, - max: payload.max || {}, - }), - }).catch(() => {}); - } catch (e) {} - } - _saveLeaderboard(scriptId, payload) { - const reqId = payload?.reqId; - const pid = this._saveProjectId(); - if (!pid) { this._saveReply(scriptId, reqId, []); return; } - const params = new URLSearchParams({ - namespace: payload?.namespace || '', - key: payload?.key || '', - order: payload?.order || 'desc', - limit: '20', - }); - fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`) - .then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.entries || [])) - .catch(() => this._saveReply(scriptId, reqId, [])); - } - - // ============== ECONOMY API (GD-reward через storys) ============== - // Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization. - // Ответ возвращается в Worker через postMessage cmd='economyResponse'. - - _economyReply(scriptId, reqId, result) { - for (const sb of this.sandboxes) { - if (sb.scriptId === scriptId) { - try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {} - return; - } - } - } - - _economyAuthHeaders() { - const h = { 'Content-Type': 'application/json' }; - try { - const t = localStorage.getItem('Authorization'); - if (t) h.Authorization = t; - } catch (e) {} - return h; - } - - _economyReward(scriptId, payload) { - const reqId = payload?.reqId; - const aid = String(payload?.achievementId || ''); - if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; } - fetch(`${STORYS_addres}/kubikon3d/gd/reward`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({ achievement_id: aid }), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) - .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); - } - - _economyDailyCheck(scriptId, payload) { - const reqId = payload?.reqId; - fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({}), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { awarded: false })) - .catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) })); - } - - _economyGetBalance(scriptId, payload) { - const reqId = payload?.reqId; - // Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating. - // Делаем оба запроса параллельно. - const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); - const headers = this._economyAuthHeaders(); - Promise.all([ - fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })), - fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })), - ]).then(([dm, rt]) => { - this._economyReply(scriptId, reqId, { - diamonds: Number(dm.count || 0), - rating: Number(rt.rating || 0), - }); - }).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 })); - } - - _economySpend(scriptId, payload) { - const reqId = payload?.reqId; - const amount = Number(payload?.amount || 0); - const reason = String(payload?.reason || 'gd_spend'); - if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; } - const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); - fetch(`${USER_BASE}/api/v1/users/diamond/spend`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({ amount, reason }), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) - .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); - } -} +/** + * GameRuntime — управляет всеми пользовательскими скриптами в режиме Play. + * + * Жизненный цикл: + * const rt = new GameRuntime(scene3d); + * rt.setOnLog(({level,text}) => console.log(text)); + * rt.start(scripts); // scripts — массив { id, code } + * ... каждый кадр rt.tick(dt) ... + * rt.stop(); // выгрузить всех Worker'ов + * + * Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь + * и применяются к BabylonScene (через player.teleport и т.п.). + * + * Этап 2.1: минимальный API — player.teleport, onTick, log. + */ + +import { Color3 } from '@babylonjs/core'; +import { ScriptSandbox } from './ScriptSandbox'; +import { STORYS_addres } from '../api/API'; + +export class GameRuntime { + constructor(scene3d) { + this.scene3d = scene3d; + /** @type {ScriptSandbox[]} */ + this.sandboxes = []; + this._onLog = null; + this._isRunning = false; + // Активные твины (game.tween). Крутятся в tick(dt). + // Каждый: { tweenId, scriptId, ref, props, from, duration, easing, + // delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft } + this._tweens = []; + // Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }. + // Общие для всех скриптов, рассылаются воркерам через dataSnapshot. + this._objectData = {}; + // Интерактивные объекты (game.self.onInteract / ProximityPrompt). + // Каждый: { target, text, distance, key }. Заполняется при + // self.registerInteract, проверяется по дистанции в tick. + this._interactables = []; + // ref ближайшего интерактивного объекта в зоне (для подсветки [E]). + this._activeInteractRef = null; + // Общее состояние комнаты для game.room.set/get (Фаза 4.3). + // В редакторе (single-player) — локальное хранилище. С Colyseus- + // комнатой будет синхронизироваться (требует серверной схемы). + this._roomState = {}; + // Сессии игроков, которых видели в прошлом tick — для детекта + // join/leave (game.onPlayerJoin / onPlayerLeave). + this._seenSessions = null; + // Команды (Фаза 4.4): name → { name, color }. + this._teams = new Map(); + // Команда локального игрока (имя) или null. + this._localPlayerTeam = null; + } + + setOnLog(cb) { this._onLog = cb; } + + /** Колбэк HUD-команд от скриптов: { cmd, payload }. */ + setOnHud(cb) { this._onHud = cb; } + + /** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */ + setOnCrosshairChange(cb) { this._onCrosshair = cb; } + + /** + * Запустить все скрипты. + * @param {Array<{id:any, code:string}>} scripts + */ + start(scripts) { + this.stop(); + this._isRunning = true; + // eslint-disable-next-line no-console + console.log('[GameRuntime] start called with scripts:', scripts); + if (!Array.isArray(scripts) || scripts.length === 0) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] start: no scripts to run'); + return; + } + // Карта модулей для game.require — { имя_скрипта: код }. + // Любой скрипт проекта можно подключить как модуль по его имени. + const modules = {}; + for (const s of scripts) { + if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') { + modules[s.name] = s.code; + } + } + // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и + // передаём прямо в init. Иначе findOne() в синхронном теле скрипта + // (на старте) возвращает null → подписки obj.onTouch/find не работают. + let initialScene = null; + try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } + for (const s of scripts) { + if (!s || typeof s.code !== 'string' || !s.code.trim()) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] skipping invalid script entry', s); + continue; + } + const sb = new ScriptSandbox(s.code, s.target || null); + sb.scriptId = s.id; + sb.setModules(modules); + if (initialScene) sb.setInitialScene(initialScene); + // Если target есть — передаём начальную позицию self до старта + if (s.target) { + const pos = this._collectSelfPosition(s.target); + if (pos) sb.setInitialSelfPosition(pos); + } + sb.setOnCommand((cmd, payload) => { + // PERF-METRICS: замер скриптов (postMessage→handle) + const _t0 = performance.now(); + this._handleCommand(s.id, cmd, payload); + const m = this.scene3d?._perfMetrics; + if (m) { + m.script_ms_sum += performance.now() - _t0; + m.script_count++; + } + }); + sb.start(); + this.sandboxes.push(sb); + // eslint-disable-next-line no-console + console.log('[GameRuntime] sandbox started for script id=', s.id); + } + this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); + // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' + // во все sandbox'ы. Не перезаписываем существующий обработчик — + // оборачиваем его (старый колбэк UI должен продолжать работать). + try { + const player = this.scene3d?.player; + if (player && !player._gameRuntimeHpHook) { + const prevCb = player._onHpChange; + this._lastSeenHp = player.hp ?? 100; + player._onHpChange = (ev) => { + if (typeof prevCb === 'function') { + try { prevCb(ev); } catch (e) {} + } + const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0); + this._lastSeenHp = ev?.hp ?? 0; + this.routeGlobalEvent('hpChange', { + hp: ev?.hp, + maxHp: ev?.maxHp, + source: ev?.source || null, + damaged: !!ev?.damaged, + delta, + }); + }; + player._gameRuntimeHpHook = true; + } + // Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand + if (player && !player._gameRuntimeMoveHook) { + player._onJump = () => this.routeGlobalEvent('playerJump', {}); + player._onLand = () => this.routeGlobalEvent('playerLand', {}); + player._gameRuntimeMoveHook = true; + } + // Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick + this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0; + // Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие + // npcDeath с id и позицией погибшего NPC. + const nm = this.scene3d?.npcManager; + if (nm && typeof nm.setOnDeath === 'function') { + nm.setOnDeath((npcId, position) => { + this.routeGlobalEvent('npcDeath', { npcId, position }); + }); + } + } catch (e) { /* ignore */ } + // Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала. + const sendInitial = () => { + this._broadcastSceneSnapshot(); + this._broadcastGuiSnapshot(); + this._broadcastTerrainHeightmap(); + this._broadcastSkinsSnapshot(); // задача 07 + // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'. + this._startGuiAnimationPresets(); + }; + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(sendInitial); + } else { + setTimeout(sendInitial, 16); + } + } + + /** + * Разослать карту высот гладкого ландшафта всем sandbox'ам. + * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по + * реальному мешу один раз — террейн в Play не меняется. + */ + _broadcastTerrainHeightmap() { + const s = this.scene3d; + if (!s || typeof s.exportRobloxHeightmap !== 'function') return; + // Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби + // (там шаг 2), для плавности движения животных достаточно. + let hm; + try { + hm = s.exportRobloxHeightmap(3); + } catch (e) { + return; + } + if (!hm || !hm.heights) return; + const payload = { + origin: hm.origin, step: hm.step, + cols: hm.cols, rows: hm.rows, heights: hm.heights, + }; + for (const sb of this.sandboxes) { + sb.sendTerrainHeightmap(payload); + } + } + + /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ + _startGuiAnimationPresets() { + const gm = this.scene3d?.guiManager; + if (!gm) return; + if (!this._guiTweens) this._guiTweens = []; + for (const el of (gm.elements || [])) { + const preset = el.animationPreset; + if (!preset || preset === 'none') continue; + const id = el.id; + // Каждый пресет = одна tween-запись с reverses+repeat=-1 + switch (preset) { + case 'pulse': + this._guiTweens.push(this._mkGuiPreset(id, el, + { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1)); + break; + case 'rotate': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1)); + break; + case 'sway': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1)); + this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8; + break; + case 'glow': + this._guiTweens.push(this._mkGuiPreset(id, el, + { bgOpacity: 0.6 }, 0.8, 'ease', true, -1)); + break; + case 'bounce': + this._guiTweens.push(this._mkGuiPreset(id, el, + { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1)); + break; + } + } + } + _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) { + const start = {}; + for (const k of Object.keys(targetProps)) { + if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1; + else if (k === 'rotation') start[k] = el.rotation || 0; + else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity; + else start[k] = el[k] || 0; + } + return { + tweenId: ++this._tweenSeq || (this._tweenSeq = 1), + scriptId: '__preset__', + realId: id, + start, target: targetProps, + elapsed: 0, delay: 0, + duration, easing, + repeat, reverses, iter: 0, dir: 1, + }; + } + + /** + * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы + * game.player.getAvailableSkins/getAllSkins работали синхронно. + * Манифест грузится через fetch (кешируется браузером), затем + * объединяется с разблокированными скинами из scene.skins. + */ + async _broadcastSkinsSnapshot() { + try { + this._ensureSkinState(); + let manifest = this._skinManifestCache; + if (!manifest) { + const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); + const json = await resp.json(); + manifest = (json.skins || []).map(s => ({ + slug: s.slug || (s.id || '').replace(/^skin_/, ''), + name: s.name || s.slug, + kind: s.kind || 'r15', + category: s.category || 'human', + price: Number.isFinite(s.price) ? s.price : 0, + })); + // Встроенные «человеки» character-a..g тоже добавим как базовый выбор. + this._skinManifestCache = manifest; + } + const payload = { + all: manifest, + unlocked: Array.from(this._skinState.unlocked), + current: this._skinState.current, + coins: this._skinState.coins, + }; + for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload); + // Также отдать снапшот в scene для React-магазина. + try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {} + } catch (e) { + // манифест недоступен — не критично, скрипт получит пустой список + } + } + + /** + * Задача 07: гарантированно инициализировать состояние скинов при первом + * обращении. Держит множество разблокированных скинов и текущий. + */ + _ensureSkinState() { + if (this._skinState) return this._skinState; + const sk = this.scene3d?._skinsConfig || {}; + const def = sk.default || this.scene3d?._playerModelType || 'character-a'; + const defSlug = this._slugFromTypeId(def); + const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []); + unlocked.add(defSlug); + this._skinState = { + unlocked, + current: defSlug, + shopVisible: sk.shopVisible !== false, + coins: Number.isFinite(sk.coins) ? sk.coins : 0, + }; + return this._skinState; + } + + /** Ленивая инициализация PlacementManager (задача 11). */ + _ensurePlacementManager() { + if (this.scene3d?.placementManager) return this.scene3d.placementManager; + if (!this.scene3d || !this.scene3d.scene) return null; + try { + if (this.scene3d._PlacementManagerClass) { + this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d); + } + } catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); } + return this.scene3d.placementManager || null; + } + + /** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */ + _ensureShopInventory() { + if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi; + if (!this.scene3d) return null; + try { + if (this.scene3d._ShopInventoryUiClass) { + this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d); + } + } catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); } + return this.scene3d.shopInventoryUi || null; + } + + /** Ленивая инициализация экрана загрузки (задача 12). */ + _ensureLoadingScreen() { + if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen; + if (!this.scene3d) return null; + try { + if (this.scene3d._LoadingScreenOverlayClass) { + const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d); + ls.setBridge( + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); }, + (id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); }, + ); + this.scene3d.loadingScreen = ls; + } + } catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); } + return this.scene3d.loadingScreen || null; + } + + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ + _resolveSkinTypeId(slug) { + if (!slug) return 'character-a'; + if (slug.startsWith('character-')) return slug; + if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug; + return 'skin_' + slug; + } + + /** _modelTypeId → slug (обратно). */ + _slugFromTypeId(typeId) { + if (!typeId) return 'character-a'; + if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length); + return typeId; + } + + /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */ + _updateGuiTweens(dt) { + const gm = this.scene3d?.guiManager; + if (!gm) return; + for (let i = this._guiTweens.length - 1; i >= 0; i--) { + const tw = this._guiTweens[i]; + if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; } + tw.elapsed += dt; + let t = tw.elapsed / tw.duration; + let done = false; + if (t >= 1) { t = 1; done = true; } + const raw = tw.dir === -1 ? 1 - t : t; + const k = GameRuntime._ease(tw.easing, raw); + // Применяем + const el = gm.elements.find(e => e.id === tw.realId); + if (!el) { this._guiTweens.splice(i, 1); continue; } + const patch = {}; + for (const key of Object.keys(tw.target)) { + const from = tw.start[key]; + const to = tw.target[key]; + if (typeof from === 'number' && typeof to === 'number') { + patch[key] = from + (to - from) * k; + } else if (typeof from === 'string' && typeof to === 'string' + && from.startsWith('#') && to.startsWith('#')) { + patch[key] = GameRuntime._lerpColor(from, to, k); + } else { + // Прочее — на конце ставим целевое + if (done) patch[key] = to; + } + } + // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS). + tw._lastApply = tw._lastApply || 0; + tw._lastApply += dt; + if (tw._lastApply >= 0.032 || done) { + tw._lastApply = 0; + try { gm.update(tw.realId, patch); } catch (e) {} + } + + if (done) { + if (tw.reverses && tw.dir === 1) { + tw.dir = -1; + tw.elapsed = 0; + continue; + } + tw.iter++; + if (tw.repeat === -1 || tw.iter < tw.repeat) { + // повтор + tw.elapsed = 0; + tw.dir = 1; + continue; + } + // готово + this._guiTweens.splice(i, 1); + // onDone callback в worker + const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId); + if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId }); + } + } + } + + /** Слить отложенные команды для конкретного только что зарезолвленного ref. */ + _drainPendingResolveQueue(resolvedLocalRef) { + if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return; + const stay = []; + for (const item of this._pendingResolveQueue) { + if (item.payload?.ref === resolvedLocalRef) { + this._handleCommand(item.scriptId, item.cmd, item.payload); + } else { + stay.push(item); + } + } + this._pendingResolveQueue = stay; + } + + /** + * Получить позицию объекта по его target (для зеркалирования в worker). + */ + _collectSelfPosition(target) { + if (!target || !this.scene3d) return null; + try { + if (target.kind === 'block') { + const r = target.ref || target; + return { x: r.x, y: r.y + 0.5, z: r.z }; + } + if (target.kind === 'model') { + const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + if (target.kind === 'primitive') { + const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + if (target.kind === 'userModel') { + const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + } catch (e) { /* ignore */ } + return null; + } + + stop() { + if (this.sandboxes.length > 0) { + this._log('info', 'Остановка скриптов'); + // eslint-disable-next-line no-console + console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); + for (const sb of this.sandboxes) sb.stop(); + } + // Удаляем все объекты, которые скрипты наспавнили через + // game.scene.spawn/clone — иначе после Stop они остаются на сцене + // и накапливаются при повторных запусках. + this._cleanupSpawnedObjects(); + // Удаляем GUI-элементы, созданные скриптом через game.gui.create — + // иначе после Stop они остаются в интерфейсе сцены. + this._cleanupSpawnedGui(); + // Убираем billboard-метки над объектами (game.scene.setLabel). + try { + if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll(); + } catch (e) { /* ignore */ } + this.sandboxes = []; + this._isRunning = false; + this._soloScriptId = null; + this._tweens = []; + this._objectData = {}; + this._interactables = []; + this._activeInteractRef = null; + // Задача 14: убрать машины и HUD водителя, чтобы при повторном start + // не плодились дубликаты (в плеере start может вызываться повторно). + try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {} + try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {} + this._vehHudShown = false; + try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {} + this._watchedTouchRefs = null; + this._watchedClickRefs = null; + this._roomState = {}; + this._seenSessions = null; + this._teams = new Map(); + this._localPlayerTeam = null; + this._constraintLocalToReal = new Map(); + this._fxLocalToReal = new Map(); + this._soundLocalToReal = new Map(); + this._guiLocalToReal = new Map(); + this._guiRealToLocal = new Map(); + } + + /** + * Удалить GUI-элементы, созданные скриптом через game.gui.create. + * Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене + * после остановки игры и копится при повторных запусках. + */ + _cleanupSpawnedGui() { + if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return; + const s = this.scene3d; + if (!s || typeof s.removeGuiElement !== 'function') return; + for (const realId of this._guiLocalToReal.values()) { + try { + // removeGuiElement каскадно удаляет детей — повторный вызов + // для уже удалённого элемента безопасен (no-op). + s.removeGuiElement(realId); + } catch (e) { /* ignore */ } + } + // removeGuiElement дёргает _notify GuiManager → KubikonEditor + // синхронит guiList. Снапшот воркерам не нужен (они остановлены). + } + + /** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */ + _cleanupSpawnedObjects() { + if (!this._localToReal || this._localToReal.size === 0) return; + const s = this.scene3d; + for (const realRef of this._localToReal.values()) { + try { + if (typeof realRef !== 'string') continue; + const colon = realRef.indexOf(':'); + if (colon < 0) continue; + const kind = realRef.slice(0, colon); + const rest = realRef.slice(colon + 1); + if (kind === 'block') { + const [xs, ys, zs] = rest.split(','); + s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); + } else if (kind === 'model') { + s?.modelManager?.removeInstance(Number(rest)); + } else if (kind === 'primitive') { + s?.primitiveManager?.removeInstance(Number(rest)); + } else if (kind === 'usermodel') { + // Воксельные модели, наспавненные скриптом (placement) — + // удаляем при Stop, иначе placed-объекты остаются. См. studio. + s?.userModelManager?.removeInstance(Number(rest)); + } + } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } + } + this._localToReal = new Map(); + } + + /** + * Запустить ОДИН скрипт без перезагрузки сцены — режим отладки. + * Останавливает другие скрипты, оставляет только заданный. + * Это альтернатива Play-режиму: без полноценного игрока, без физики, но + * скрипты получают зеркало state и могут вызывать game.log/teleport. + * + * Используется из ScriptEditor → кнопка «Запустить только этот». + */ + startSolo(script) { + this.stop(); + this._isRunning = true; + this._soloScriptId = script?.id || null; + if (!script || typeof script.code !== 'string' || !script.code.trim()) { + this._log('warn', 'Solo-запуск: пустой код'); + return; + } + const sb = new ScriptSandbox(script.code, script.target || null); + sb.scriptId = script.id; + if (script.target) { + const pos = this._collectSelfPosition(script.target); + if (pos) sb.setInitialSelfPosition(pos); + } + sb.setOnCommand((cmd, payload) => { + const _t0 = performance.now(); + this._handleCommand(script.id, cmd, payload); + const m = this.scene3d?._perfMetrics; + if (m) { + m.script_ms_sum += performance.now() - _t0; + m.script_count++; + } + }); + sb.start(); + this.sandboxes.push(sb); + this._log('info', `Отладочный запуск: ${script.id}`); + } + + /** True если runtime работает в solo-режиме (один скрипт). */ + isSolo() { return !!this._soloScriptId; } + getSoloScriptId() { return this._soloScriptId; } + + /** + * Вызывать каждый кадр в Play-режиме. + * dt в секундах. + */ + tick(dt) { + if (!this._isRunning || this.sandboxes.length === 0) return; + const state = this._collectState(); + for (const sb of this.sandboxes) { + // Для скриптов с target — добавляем актуальную позицию self + const stateForSb = sb.target + ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } + : state; + sb.tick(dt, stateForSb); + } + // Анимации game.tween + if (this._tweens.length > 0) this._updateTweens(dt); + // Задача 03: GUI tweens + if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt); + + // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом + if (this._interactables.length > 0) this._updateInteractables(); + + // Задача 14: HUD водителя. + this._updateVehicleHud(); + + // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть) + const hp = this.scene3d?.player?.hp ?? 100; + const aliveNow = hp > 0; + if (this._playerWasAlive && !aliveNow) { + this.routeGlobalEvent('playerDied', {}); + } + this._playerWasAlive = aliveNow; + + // Детект join/leave игроков комнаты (Фаза 4.3). + this._detectPlayerJoinLeave(state.players); + } + + /** + * Сравнить текущий список игроков с прошлым tick — событие + * playerJoin для новых, playerLeave для исчезнувших. + * Локального игрока не учитываем (он не «присоединяется»). + */ + _detectPlayerJoinLeave(players) { + if (!players || !players.list) return; + const now = new Map(); + for (const p of players.list) { + if (!p.isLocal) now.set(p.sessionId, p); + } + if (this._seenSessions == null) { + // Первый tick — фиксируем без событий (это «уже были»). + this._seenSessions = now; + return; + } + for (const [sid, p] of now) { + if (!this._seenSessions.has(sid)) { + this.routeGlobalEvent('playerJoin', { + sessionId: sid, name: p.name, + }); + } + } + for (const [sid, p] of this._seenSessions) { + if (!now.has(sid)) { + this.routeGlobalEvent('playerLeave', { + sessionId: sid, name: p.name, + }); + } + } + this._seenSessions = now; + } + + /** + * Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens. + * payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo } + */ + _startTween(scriptId, payload) { + try { + const { tweenId, ref, props } = payload || {}; + if (tweenId == null || typeof ref !== 'string' || !props) return; + const from = {}; + let guiId = null; + + // --- цель: GUI или 3D-объект --- + // GUI-id: либо локальный ref (gui.create), либо реальный id + let resolvedGuiId = ref; + if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref); + const guiList = this.scene3d?.getGuiElements?.() || []; + const guiEl = guiList.find(g => g.id === resolvedGuiId); + + if (guiEl) { + guiId = resolvedGuiId; + // числовые свойства GUI + for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) { + if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]); + } + // цвет + if (props.color != null && guiEl.bgColor) { + from._color = Color3.FromHexString(guiEl.bgColor); + from._colorTo = Color3.FromHexString(String(props.color)); + } + if (props.textColor != null && guiEl.textColor) { + from._color = Color3.FromHexString(guiEl.textColor); + from._colorTo = Color3.FromHexString(String(props.textColor)); + } + } else { + // 3D-объект + const tgt = this._resolveTweenTarget(ref); + if (!tgt) { + this._log('error', 'tween: объект не найден — ' + ref); + return; + } + const d = tgt.data; + from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0; + from.rotationX = d.rotationX || 0; + from.rotationY = d.rotationY || 0; + from.rotationZ = d.rotationZ || 0; + from.sx = d.sx != null ? d.sx : 1; + from.sy = d.sy != null ? d.sy : 1; + from.sz = d.sz != null ? d.sz : 1; + from.opacity = d.opacity != null ? d.opacity + : (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1); + if (props.color != null) { + const cur = d.color || '#ffffff'; + from._color = Color3.FromHexString(cur); + from._colorTo = Color3.FromHexString(String(props.color)); + } + } + + this._tweens.push({ + tweenId, scriptId, ref, guiId, + props, from, + duration: Math.max(0, Number(payload.duration) || 0), + easing: payload.easing || 'ease', + delayLeft: Math.max(0, Number(payload.delay) || 0), + loopsLeft: Number(payload.repeat) || 0, // 0=без повтора, -1=бесконечно + yoyo: !!payload.yoyo, + elapsed: 0, + dir: 1, + }); + } catch (e) { + this._log('error', 'tween.start failed: ' + (e?.message || e)); + } + } + + /** + * ProximityPrompt: каждый кадр ищем ближайший интерактивный объект + * в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка). + */ + _updateInteractables() { + const player = this.scene3d?.player; + const pp = player?._pos; + if (!pp) return; + const halfH = player?.HALF_H ?? 0.9; + const px = pp.x, py = pp.y - halfH, pz = pp.z; + + let nearest = null; + let nearestD2 = Infinity; + for (const it of this._interactables) { + const objPos = this._resolveInteractPos(it); + if (!objPos) continue; + const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz; + const d2 = dx*dx + dy*dy + dz*dz; + const r = it.distance; + if (d2 <= r*r && d2 < nearestD2) { + nearestD2 = d2; + nearest = it; + } + } + + const nearestRef = nearest ? nearest.ref : null; + if (nearestRef !== this._activeInteractRef) { + this._activeInteractRef = nearestRef; + if (nearest) { + // показываем подсказку через HUD (как game.ui.set) + if (this._onHud) { + try { + this._onHud({ cmd: 'ui.set', payload: { + id: '__interact', + text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text, + opts: { x: 50, y: 75, color: '#ffe44a', size: 20 }, + } }); + } catch (e) { /* ignore */ } + } + } else { + // вышли из зоны — убираем подсказку + if (this._onHud) { + try { + this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } }); + } catch (e) { /* ignore */ } + } + } + } + } + + /** Резолв позиции интерактивного объекта (по ref). */ + _updateVehicleHud() { + const player = this.scene3d?.player; + const veh = player?._inVehicle; + if (veh) { + const hud = this._ensureVehicleHud(); + if (hud) { + if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; } + try { hud.update(veh.speed); } catch (e) {} + } + } else if (this._vehHudShown) { + this._vehHudShown = false; + try { this.scene3d?.vehicleHud?.remove(); } catch (e) {} + } + } + + _ensureVehicleHud() { + if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud; + if (!this.scene3d || !this.scene3d._VehicleHudClass) return null; + try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); } + catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); } + return this.scene3d.vehicleHud || null; + } + + _resolveInteractPos(it) { + if (it.ref && it.ref.indexOf('vehicle:') === 0) { + const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8))); + return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null; + } + const tgt = this._resolveTweenTarget(it.ref); + if (tgt) { + const d = tgt.data; + return { x: d.x || 0, y: d.y || 0, z: d.z || 0 }; + } + return null; + } + + /** + * Нажата клавиша взаимодействия (E) — отправить событие 'interact' + * скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent + * при keydown. + */ + _tryInteract(key) { + if (!this._activeInteractRef) return; + const it = this._interactables.find(x => x.ref === this._activeInteractRef); + if (!it || it.key !== String(key).toLowerCase()) return; + this._fireInteract(it); + } + + _fireInteract(it) { + if (!it) return; + if (it.isInst) { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref }); + } else if (it.target) { + this.routeEvent(it.target, 'interact', {}); + } + if (it.ref && it.ref.indexOf('vehicle:') === 0) { + const vid = Number(it.ref.slice(8)); + const veh = this.scene3d?.vehicleManager?.getById?.(vid); + const player = this.scene3d?.player; + if (veh && player && !player._inVehicle) { + player.enterVehicle(veh); + player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); }; + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid }); + } + } + } + + /** Прокрутка всех активных твинов на dt секунд. */ + _updateTweens(dt) { + for (let i = this._tweens.length - 1; i >= 0; i--) { + const tw = this._tweens[i]; + // задержка перед стартом + if (tw.delayLeft > 0) { + tw.delayLeft -= dt; + if (tw.delayLeft > 0) continue; + dt = -tw.delayLeft; // остаток времени уходит в анимацию + } + tw.elapsed += dt; + let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1; + let done = false; + if (t >= 1) { + t = 1; + done = true; + } + // прогресс с учётом направления (yoyo) + easing + const raw = tw.dir === -1 ? 1 - t : t; + const k = GameRuntime._ease(tw.easing, raw); + this._applyTweenFrame(tw, k); + + if (done) { + if (tw.yoyo && tw.dir === 1) { + // первый проход «туда» завершён — разворачиваем «обратно» + tw.dir = -1; + tw.elapsed = 0; + continue; + } + // цикл завершён полностью (или прямой, или yoyo туда-обратно) + if (tw.loopsLeft !== 0) { + if (tw.loopsLeft > 0) tw.loopsLeft--; + tw.dir = 1; + tw.elapsed = 0; + continue; + } + // твин закончен — снять и уведомить скрипт + this._tweens.splice(i, 1); + this._notifyTweenDone(tw.scriptId, tw.tweenId); + } + } + } + + /** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */ + static _ease(name, t) { + switch (name) { + case 'linear': + return t; + case 'bounce': { + const n1 = 7.5625, d1 = 2.75; + if (t < 1 / d1) return n1 * t * t; + if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } + if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } + t -= 2.625 / d1; return n1 * t * t + 0.984375; + } + case 'elastic': { + if (t === 0 || t === 1) return t; + const c4 = (2 * Math.PI) / 3; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + } + case 'back': { + const c1 = 1.70158, c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + } + case 'ease': + default: + // ease-in-out (плавный старт и финиш) + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + } + } + + /** Уведомить воркер скрипта что твин доиграл (resolve onDone). */ + _notifyTweenDone(scriptId, tweenId) { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && sb.worker) { + try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {} + } + } + + /** + * Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после + * scene.spawn. Нужно чтобы синхронные read-методы воркера + * (getPosition и т.п.) резолвили локальный ref в реальный — иначе + * заспавненный объект не находится в _sceneIndex (там реальные ref). + */ + _notifySpawnResolved(localRef, realRef) { + if (!localRef || !realRef) return; + // Объект мог быть удалён скриптом ДО того как зарезолвился + // (асинхронный спавн GLB-модели). Если он в очереди отложенных + // удалений — удаляем сейчас, когда реальный id известен. + if (this._pendingDeletes && this._pendingDeletes.has(localRef)) { + this._pendingDeletes.delete(localRef); + try { + this._applySceneDelete({ ref: realRef }); + } catch (e) { /* ignore */ } + return; + } + for (const sb of this.sandboxes) { + if (sb && sb.worker) { + try { + sb.worker.postMessage({ + cmd: 'spawnResolved', + payload: { localRef, realRef }, + }); + } catch (e) { /* ignore */ } + } + } + } + + /** + * Резолв ref в инстанс-данные объекта сцены. + * Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'. + * data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z). + */ + /** + * Резолв id примитива из любого вида ссылки в реальный id для + * primitiveManager.instances. Принимает: + * - реальный числовой id (или строку-число) + * - локальный ref от spawn/clone ('primitive:_local_N') + * - ref 'primitive:realId' + * Возвращает id (число) или null. + */ + _resolvePrimitiveId(idOrRef) { + if (idOrRef == null) return null; + const pm = this.scene3d?.primitiveManager; + if (!pm) return null; + let v = idOrRef; + if (typeof v === 'string') { + // полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту + if (this._localToReal?.has(v)) v = this._localToReal.get(v); + const colon = v.indexOf(':'); + if (colon >= 0) v = v.slice(colon + 1); + // голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте: + // ключ 'primitive:_local_N' → значение 'primitive:realId'. + if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) { + const full = 'primitive:' + v; + if (this._localToReal.has(full)) { + const real = this._localToReal.get(full); + const c2 = real.indexOf(':'); + v = c2 >= 0 ? real.slice(c2 + 1) : real; + } + } + } + // прямой id + if (pm.instances.has(v)) return v; + const n = Number(v); + if (Number.isFinite(n) && pm.instances.has(n)) return n; + return null; + } + + /** + * ref NPC ('npc:_local_N' от воркера или 'npc:') → числовой npcId. + * Возвращает number или null. + */ + _resolveNpcId(ref) { + if (typeof ref !== 'string') return null; + let v = ref; + // Локальный ref воркера → реальный 'npc:'. + if (this._localToReal?.has(v)) v = this._localToReal.get(v); + const colon = v.indexOf(':'); + if (colon < 0) return null; + const id = Number(v.slice(colon + 1)); + return Number.isFinite(id) ? id : null; + } + + /** + * Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а + * скрипт сразу зовёт follow/moveTo/say) — откладываем команду в + * очередь по локальному ref и проигрываем после npcSpawned-резолва. + * Без этого команды сразу после spawnNpc молча терялись. + */ + _npcCmd(ref, fn) { + const nid = this._resolveNpcId(ref); + if (nid != null) { fn(nid); return; } + // ещё не резолвится — откладываем (только для локальных ref NPC) + if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) { + if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map(); + if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); + this._pendingNpcCmds.get(ref).push(fn); + } + } + + /** Проиграть отложенные команды для NPC после его резолва. */ + _flushPendingNpcCmds(localRef, npcId) { + if (!this._pendingNpcCmds) return; + const queue = this._pendingNpcCmds.get(localRef); + if (!queue) return; + this._pendingNpcCmds.delete(localRef); + for (const fn of queue) { + try { fn(npcId); } catch (e) { /* ignore */ } + } + } + + /** Локальный ref связи ('constraint:_local_N') → числовой id или null. */ + _resolveConstraintId(ref) { + if (typeof ref !== 'string') return null; + if (this._constraintLocalToReal?.has(ref)) { + return this._constraintLocalToReal.get(ref); + } + // Запасной путь: прямой числовой id в строке. + const colon = ref.indexOf(':'); + const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); + return Number.isFinite(id) ? id : null; + } + + /** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */ + _resolveFxId(ref) { + if (typeof ref !== 'string') return null; + if (this._fxLocalToReal?.has(ref)) { + return this._fxLocalToReal.get(ref); + } + const colon = ref.indexOf(':'); + const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); + return Number.isFinite(id) ? id : null; + } + + _resolveTweenTarget(ref) { + if (typeof ref !== 'string') return null; + // Локальный ref из scene.spawn ('primitive:_local_N') → реальный id + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + const colon = ref.indexOf(':'); + const kind = colon >= 0 ? ref.slice(0, colon) : null; + const rawId = colon >= 0 ? ref.slice(colon + 1) : ref; + const tryGet = (mgr) => { + if (!mgr || !mgr.instances) return null; + let d = mgr.instances.get(rawId); + if (!d) { + const n = Number(rawId); + if (Number.isFinite(n)) d = mgr.instances.get(n); + } + return d || null; + }; + if (kind === 'primitive' || kind == null) { + const d = tryGet(this.scene3d?.primitiveManager); + if (d) return { kind: 'primitive', data: d }; + } + if (kind === 'model' || kind == null) { + const d = tryGet(this.scene3d?.modelManager); + if (d) return { kind: 'model', data: d }; + } + const um = tryGet(this.scene3d?.userModelManager); + if (um) return { kind: 'userModel', data: um }; + return null; + } + + /** + * Применить промежуточное состояние твина к объекту. + * k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу. + */ + _applyTweenFrame(tw, k) { + const lerp = (a, b) => a + (b - a) * k; + // --- GUI-элемент --- + if (tw.guiId != null) { + const patch = {}; + for (const key of Object.keys(tw.props)) { + if (key === 'color' || key === 'textColor') continue; + if (tw.from[key] == null) continue; + patch[key] = lerp(tw.from[key], Number(tw.props[key])); + } + if (tw.props.color != null || tw.props.textColor != null) { + const ck = tw.props.color != null ? 'color' : 'textColor'; + patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k); + } + // обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр) + try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {} + return; + } + // --- 3D-объект --- + const tgt = this._resolveTweenTarget(tw.ref); + if (!tgt) return; + const d = tgt.data; + const p = tw.props, f = tw.from; + // позиция + let posChanged = false; + if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; } + if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; } + if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; } + // поворот + let rotChanged = false; + if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; } + if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; } + if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; } + // масштаб + let scaleChanged = false; + if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; } + if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; } + if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; } + // меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode) + const mesh = d.mesh || d.rootMesh || d.rootNode; + if (mesh) { + if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z); + if (rotChanged && mesh.rotation) { + mesh.rotation.x = d.rotationX || 0; + mesh.rotation.y = d.rotationY || 0; + mesh.rotation.z = d.rotationZ || 0; + } + if (scaleChanged && mesh.scaling) { + mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1); + } + // размороз world-matrix если был заморожен + if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) { + try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} + d._worldMatrixFrozen = false; + } + } + // цвет + if (p.color != null && f._color != null && mesh?.material) { + const c = GameRuntime._lerpColor3(f._color, f._colorTo, k); + mesh.material.diffuseColor = c; + if (d.material === 'neon') mesh.material.emissiveColor = c; + d.color = '#' + c.toHexString().slice(1); + } + // прозрачность + if (p.opacity != null && mesh?.material) { + const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity)); + mesh.material.alpha = op; + d.opacity = op; + } + } + + /** Интерполяция цвета (Babylon Color3) между двумя hex. */ + static _lerpColor3(from, to, k) { + return new Color3( + from.r + (to.r - from.r) * k, + from.g + (to.g - from.g) * k, + from.b + (to.b - from.b) * k, + ); + } + + /** Интерполяция цвета → hex-строка (для GUI). */ + static _lerpColor(from, to, k) { + return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1); + } + + /** + * Маршрутизация событий объектов к скриптам с соответствующим target. + * Вызывается из BabylonScene при клике/touch. + * + * @param {object} target — {kind, ref|x|y|z|id} + * @param {string} eventType — 'click' | 'touch' + * @param {object} extra — дополнительные данные события + */ + routeEvent(target, eventType, extra = {}) { + if (!target || !eventType) return; + for (const sb of this.sandboxes) { + if (!sb.target) continue; + if (!this._targetMatches(sb.target, target)) continue; + sb.sendEvent({ type: eventType, ...extra }); + } + } + + /** + * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target). + * Используется для onKey, onClick (глобальный), onPlayerTouch. + */ + routeGlobalEvent(eventType, extra = {}) { + if (!eventType) return; + // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя + // способами: + // 1) по локальному ref, который вернул gui.create() — '_gui_local_N' + // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }), + // или по name элемента. + // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2), + // потому что worker искал handler по localRef, а юзер подписался по + // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref), + // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker). + if ((eventType === 'guiClick' || eventType === 'guiSubmit' + || eventType === 'guiTextChange') + && extra && extra.id != null && this._guiRealToLocal) { + const local = this._guiRealToLocal.get(extra.id); + if (local && local !== extra.id) extra = { ...extra, localId: local }; + } + // ProximityPrompt: keydown клавиши взаимодействия → событие interact + if (eventType === 'keydown' && extra && extra.key + && this._interactables.length > 0) { + this._tryInteract(extra.key); + } + for (const sb of this.sandboxes) { + sb.sendGlobalEvent({ type: eventType, ...extra }); + } + } + + /** + * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref. + * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref; + * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick. + */ + routeInstEvent(ref, type, extra = {}) { + if (!ref || !type) return; + this.routeGlobalEvent(type, { ref, ...extra }); + } + + /** + * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. + * Скрипт может подписаться через `game.onMobKilled(fn)`. + * payload: { type: 'zombie' | ..., x, y, z } + */ + notifyMobKilled(mobType, position) { + this.routeGlobalEvent('mobKilled', { mobType, position }); + } + + /** Совпадает ли target скрипта с обращённым target события. */ + _targetMatches(a, b) { + if (!a || !b) return false; + if (a.kind !== b.kind) return false; + if (a.kind === 'block') { + const ar = a.ref || a; + const br = b.ref || b; + return ar.x === br.x && ar.y === br.y && ar.z === br.z; + } + const aId = a.id ?? a.ref; + const bId = b.id ?? b.ref; + return aId === bId; + } + + /** Собрать снимок state для отправки в Worker'ы. */ + _collectState() { + const player = this.scene3d?.player; + // PlayerController хранит позицию в this._pos (Vector3). + // Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов + // удобнее давать «низ ног» = _pos.y - HALF_H. + const p = player?._pos; + const halfH = player?.HALF_H ?? 0.9; + const position = p + ? { x: p.x, y: p.y - halfH, z: p.z } + : { x: 0, y: 0, z: 0 }; + // Yaw/pitch (для player.forward) + const yaw = player?._yaw || 0; + const pitch = player?._pitch || 0; + // Forward-вектор. PlayerController использует: + // fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch) + const cosP = Math.cos(pitch); + const forward = { + x: Math.sin(yaw) * cosP, + y: -Math.sin(pitch), + z: Math.cos(yaw) * cosP, + }; + const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none'; + const hp = player?.hp ?? 100; + const maxHp = player?.maxHp ?? 100; + // Снимок мобов (зомби) — для game.scene.mobs() из скриптов + let mobs = []; + try { + const zm = this.scene3d?.zombieManager; + if (zm && typeof zm.getMobsSnapshot === 'function') { + mobs = zm.getMobsSnapshot(); + } + } catch (e) {} + // Снимок NPC — для game.scene.npcs() и npc.position из скриптов. + let npcs = []; + try { + const nm = this.scene3d?.npcManager; + if (nm && typeof nm.getSnapshot === 'function') { + npcs = nm.getSnapshot(); + } + } catch (e) {} + // Снимок инвентаря — для game.inventory.has/list/active. + let inventory = null; + try { + const inv = this.scene3d?.inventory; + if (inv) { + inventory = { + slots: inv.slots.map(s => s ? { + kind: s.kind, modelTypeId: s.modelTypeId, name: s.name, + } : null), + activeIndex: inv.activeIndex, + }; + } + } catch (e) {} + // Снимок игроков комнаты — для game.players.* (Фаза 4.3). + // В редакторе (single-player) — только локальный игрок. + // С мультиплеером — локальный + все remote из _mpSync. + const players = this._collectPlayers(position, hp, maxHp); + // Кубикон Dash: текущее направление гравитации (+1 / -1). + // Нужно скрипту для рендера куба в правильной ориентации. + const gravityDir = player?._gravityDir ?? 1; + // Состояние игрока ('ground'|'air'|'water') для game.player.state. + const state = player?._playerState || 'ground'; + // Зажатые клавиши — для game.player.isKeyDown(key). + // _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта. + const keys = {}; + if (player?._codes) { + for (const code of player._codes) { + const k = GameRuntime._normalizeKeyCode(code); + if (k) keys[k] = true; + } + } + return { + player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys }, + mobs, + npcs, + inventory, + players, + roomState: this._roomState || {}, + teams: this._teams ? Array.from(this._teams.values()) : [], + }; + } + + /** + * Снимок всех игроков комнаты для game.players.* (Фаза 4.3). + * Локальный игрок всегда первый, sessionId='local' в одиночной игре + * или реальный sessionId если есть Colyseus-комната. + * Возвращает { me, list } — list включает me. + */ + _collectPlayers(myPos, myHp, myMaxHp) { + const mp = this.scene3d?._mpSync; + const mySessionId = mp?.room?.sessionId || 'local'; + const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username + || this._localPlayerName || 'Игрок'; + const me = { + sessionId: mySessionId, + name: myName, + isLocal: true, + position: myPos, + hp: myHp, maxHp: myMaxHp, + team: this._localPlayerTeam || null, + }; + const list = [me]; + // Remote-игроки из MultiplayerSync (если есть комната). + if (mp && mp.remotePlayers) { + const roomPlayers = mp.room?.state?.players; + for (const rp of mp.remotePlayers.values()) { + // team берётся из Colyseus-state (его синхронизирует сервер). + const colyP = roomPlayers?.get?.(rp.sessionId); + list.push({ + sessionId: rp.sessionId, + name: rp.username || rp.sessionId, + isLocal: false, + position: rp.current + ? { x: rp.current.x, y: rp.current.y, z: rp.current.z } + : { x: 0, y: 0, z: 0 }, + hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100, + team: (colyP && colyP.team) || null, + }); + } + } + return { me, list }; + } + + /** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */ + static _normalizeKeyCode(code) { + if (!code) return null; + if (code.startsWith('Key')) return code.slice(3).toLowerCase(); // KeyW → w + if (code.startsWith('Digit')) return code.slice(5); // Digit1 → 1 + if (code.startsWith('Arrow')) return code.toLowerCase(); // ArrowUp → arrowup + const map = { + Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift', + Enter: 'enter', Escape: 'escape', + ControlLeft: 'ctrl', ControlRight: 'ctrl', + }; + return map[code] || code.toLowerCase(); + } + + /** Команда от Worker'а пришла — применяем на сцене. */ + _handleCommand(scriptId, cmd, payload) { + if (cmd === 'log') { + this._log(payload?.level || 'info', payload?.text || ''); + return; + } + // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик + // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает + // следить за AABB этого объекта в _detectTouchEvents и слать обратно + // instTouch/instUntouch (через routeInstEvent). + if (cmd === 'inst.watchTouch') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set(); + this._watchedTouchRefs.add(ref); + } + return; + } + if (cmd === 'inst.watchClick') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedClickRefs) this._watchedClickRefs = new Set(); + this._watchedClickRefs.add(ref); + } + return; + } + if (cmd === 'player.teleport') { + const player = this.scene3d?.player; + if (player && player._pos && payload) { + const { x, y, z } = payload; + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + try { + const halfH = player.HALF_H ?? 0.9; + // Конвертируем «низ ног» обратно в центр капсулы + player._pos.set(x, y + halfH, z); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] teleport failed', e); + } + } + } else { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) }); + } + return; + } + if (cmd === 'player.setLaneX') { + // Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для + // раннеров (смена полосы): teleport(x,y,z) затирал бы Z, + // отменяя продвижение autorun каждый кадр. + const player = this.scene3d?.player; + if (player && player._pos && payload) { + const x = Number(payload.x); + if (Number.isFinite(x)) { + try { player._pos.x = x; } catch (e) { /* ignore */ } + } + } + return; + } + if (cmd === 'player.damage') { + const player = this.scene3d?.player; + if (player && typeof player.takeDamage === 'function') { + const amt = Math.max(0, Number(payload?.amount) || 0); + if (amt > 0) { + // Если урон больше maxHp — обходим i-frames для kill(). + if (amt >= (player.maxHp ?? 100)) { + player._lastDamageTime = 0; // сбрасываем cooldown + } + try { player.takeDamage(amt, 'script'); } catch (e) {} + } + } + return; + } + if (cmd === 'player.heal') { + const player = this.scene3d?.player; + if (player && typeof player.hp === 'number') { + const amt = Math.max(0, Number(payload?.amount) || 0); + player.hp = Math.min(player.maxHp ?? 100, player.hp + amt); + if (player._onHpChange) { + try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {} + } + } + return; + } + if (cmd === 'player.respawn') { + const player = this.scene3d?.player; + if (player && player._pos) { + // Восстанавливаем HP + player.hp = player.maxHp ?? 100; + if (player._onHpChange) { + try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {} + } + // Возвращаем модель если была спрятана при смерти + if (player._modelRoot) player._modelRoot.setEnabled(true); + // Телепорт на spawnPoint сцены + const sp = this.scene3d?._spawnPoint + || this.scene3d?.scene?.metadata?.spawnPoint + || { x: 0, y: 1, z: 0 }; + const halfH = player.HALF_H ?? 0.9; + try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {} + // Сбросим скорость падения + if (player._velocity) { + try { player._velocity.set(0, 0, 0); } catch (e) {} + } + } + return; + } + if (cmd === 'player.setSpawn') { + // Назначить активную точку возрождения. Меняем scene3d._spawnPoint — + // им пользуется player.respawn и логика смерти. + const s = this.scene3d; + if (s && payload) { + let sp = null; + if (typeof payload.ref === 'string') { + // ref объекта: встаём НАД ним (центр + полувысота + зазор). + const ref = payload.ref; + if (ref.indexOf('block:') === 0) { + const [bx, by, bz] = ref.slice(6).split(',').map(Number); + if ([bx, by, bz].every(Number.isFinite)) { + sp = { x: bx, y: by + 1.1, z: bz }; + } + } else { + const tgt = this._resolveTweenTarget(ref); + if (tgt && tgt.data) { + const d = tgt.data; + const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1; + sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z }; + } + } + } else if (Number.isFinite(payload.x)) { + sp = { x: payload.x, y: payload.y, z: payload.z }; + } + if (sp && typeof s.setSpawnPoint === 'function') { + s.setSpawnPoint(sp.x, sp.y, sp.z); + } + } + return; + } + // === NPC API (Фаза 4.1) === + if (cmd === 'npc.spawn') { + // payload: { modelType, ref, x, y, z, rotationY, hp, name, speed } + const nm = this.scene3d?.npcManager; + if (nm && payload) { + if (!this._localToReal) this._localToReal = new Map(); + const p = nm.spawnNpc(payload.modelType, { + x: payload.x, y: payload.y, z: payload.z, + rotationY: payload.rotationY, + hp: payload.hp, name: payload.name, speed: payload.speed, + }); + Promise.resolve(p).then((npcId) => { + if (npcId == null) { + this._log('error', 'spawnNpc не удался: ' + payload.modelType); + return; + } + // Локальный ref воркера → реальный 'npc:'. + if (payload.ref) { + this._localToReal.set(payload.ref, 'npc:' + npcId); + // Проигрываем команды, отправленные скриптом сразу + // после spawnNpc (follow/moveTo/say) — они ждали + // резолва ref в очереди. + this._flushPendingNpcCmds(payload.ref, npcId); + } + // Сообщаем воркеру маппинг localRef → npcId, чтобы + // npc.onDeath по локальному ref находил правильного NPC. + if (payload.ref) { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && sb.worker) { + try { + sb.worker.postMessage({ + cmd: 'npcSpawned', + payload: { localRef: payload.ref, npcId }, + }); + } catch (e) { /* ignore */ } + } + } + }).catch((err) => { + this._log('error', 'spawnNpc failed: ' + (err?.message || err)); + }); + } + return; + } + if (cmd === 'npc.moveTo') { + // _npcCmd откладывает команду, если NPC ещё не создан (async). + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z)); + return; + } + if (cmd === 'npc.follow') { + this._npcCmd(payload?.ref, (nid) => { + // target — ref объекта или 'player'. Резолвим локальный ref + // в реальный (объект мог быть заспавнен скриптом). + let target = payload?.target; + if (typeof target === 'string' && this._localToReal?.has(target)) { + target = this._localToReal.get(target); + } + this.scene3d?.npcManager?.follow(nid, target); + }); + return; + } + if (cmd === 'npc.stop') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.stopNpc(nid)); + return; + } + if (cmd === 'npc.setSpeed') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.setSpeed(nid, payload?.speed)); + return; + } + if (cmd === 'npc.say') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration)); + return; + } + if (cmd === 'npc.damage') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.damage(nid, payload?.amount)); + return; + } + if (cmd === 'npc.remove') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.removeNpc(nid)); + return; + } + // === Constraints / связи объектов (Фаза 5) === + if (cmd === 'constraint.create') { + // payload: { kind: 'weld'|'hinge'|'spring', localRef, ... } + const cm = this.scene3d?.constraintManager; + if (cm && payload) { + let id = null; + if (payload.kind === 'weld') { + id = cm.addWeld(payload.refA, payload.refB); + } else if (payload.kind === 'hinge') { + id = cm.addHinge(payload.ref, { + pivotX: payload.pivotX, pivotZ: payload.pivotZ, + angle: payload.angle, + }); + } else if (payload.kind === 'spring') { + id = cm.addSpring(payload.ref, { + stiffness: payload.stiffness, damping: payload.damping, + }); + } + if (id == null) { + this._log('error', 'не удалось создать связь ' + payload.kind); + } else if (payload.localRef) { + // Маппинг localRef → реальный id (как у NPC). + if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map(); + this._constraintLocalToReal.set(payload.localRef, id); + } + } + return; + } + if (cmd === 'constraint.hingeAngle') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg); + return; + } + if (cmd === 'constraint.springPush') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) { + this.scene3d?.constraintManager?.pushSpring( + cid, payload?.vx, payload?.vy, payload?.vz); + } + return; + } + if (cmd === 'constraint.remove') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) this.scene3d?.constraintManager?.remove(cid); + return; + } + // === Beam / Trail — лучи и следы (Фаза 5.2) === + // === Placement mode (задача 11) === + if (cmd === 'placement.start') { + const pm = this._ensurePlacementManager(); + if (pm && payload) { + pm.setCallbacks({ + onPlace: (res) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); }, + onCancel: () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); }, + onMove: (mv) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); }, + }); + try { pm.start(payload.itemKey, payload.opts || {}); } + catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; } + if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; } + if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; } + if (cmd === 'inventoryUi.create') { + const im = this._ensureShopInventory(); + if (im && payload) { + try { im.create(payload, (item) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); }); } + catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'inventoryUi.setBalance') { + this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount); + this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount); + return; + } + if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; } + + // === Экран загрузки (задача 12) === + if (cmd === 'loading.show') { + const ls = this._ensureLoadingScreen(); + if (ls && payload) { + try { + const id = ls.show(payload.opts || {}); + if (payload.replyId != null) { + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id }); + } + } catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; } + if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; } + if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } + if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; } + + if (cmd === 'fx.create') { + // payload: { kind: 'beam'|'trail', localRef, ... } + const bm = this.scene3d?.beamManager; + if (bm && payload) { + let id = null; + if (payload.kind === 'beam') { + id = bm.addBeam({ + from: payload.from, to: payload.to, + 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') { + id = bm.addTrail(payload.ref, { + color: payload.color, width: payload.width, + lifetime: payload.lifetime, + }); + } + if (id == null) { + this._log('error', 'не удалось создать ' + payload.kind); + } else if (payload.localRef) { + if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); + this._fxLocalToReal.set(payload.localRef, id); + } + } + return; + } + if (cmd === 'fx.beamColor') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color); + 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') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) { + this.scene3d?.beamManager?.setBeamEndpoints( + fid, payload?.from, payload?.to); + } + return; + } + if (cmd === 'fx.remove') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.remove(fid); + return; + } + // === Звук — game.sound.* (Фаза 5.5) === + // Пользовательский звук из библиотеки проекта (Фаза 5.5). + // Встроенные пресеты ({name} без soundId) обрабатывает старый + // обработчик ниже — здесь только {soundId}. + if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') { + const sm = this.scene3d?.soundManager; + if (sm && this.scene3d?.soundLibrary?.count() > 0) { + // attachRef может быть локальным ref от scene.spawn — резолвим. + let attachRef = payload.attachRef; + if (typeof attachRef === 'string' && attachRef !== 'player' + && this._localToReal?.has(attachRef)) { + attachRef = this._localToReal.get(attachRef); + } + const instId = sm.play(payload.soundId, { + volume: payload.volume, + loop: payload.loop, + at: payload.at, + attachRef, + }); + if (instId != null && payload.localRef) { + if (!this._soundLocalToReal) this._soundLocalToReal = new Map(); + this._soundLocalToReal.set(payload.localRef, instId); + } + } + return; + } + if (cmd === 'sound.stop') { + const ref = payload?.ref; + if (ref != null && this.scene3d?.soundManager) { + const instId = this._soundLocalToReal?.has(ref) + ? this._soundLocalToReal.get(ref) : Number(ref); + if (Number.isFinite(instId)) { + this.scene3d.soundManager.stopSound(instId); + } + } + return; + } + // === Tool / инвентарь API (Фаза 4.2) === + if (cmd === 'inventory.give') { + // payload: { kind, modelTypeId, name, params } + const inv = this.scene3d?.inventory; + if (inv && payload) { + const idx = inv.add({ + kind: payload.kind || 'item', + modelTypeId: payload.modelTypeId || null, + name: payload.name || 'Предмет', + params: payload.params || {}, + }); + if (idx < 0) { + this._log('error', 'инвентарь полон — предмет не добавлен'); + } else if (payload.equip) { + // Сразу сделать активным и снарядить (для giveTool). + inv.setActive(idx); + const item = inv.slots[idx]; + if (item && item.kind === 'weapon' && this.scene3d?.weapons) { + try { this.scene3d.weapons.equip(item); } catch (e) {} + } + } + } + return; + } + if (cmd === 'inventory.remove') { + // payload: { modelTypeId? , name? } — убрать первый совпавший слот. + const inv = this.scene3d?.inventory; + if (inv && payload) { + const slots = inv.slots; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (!s) continue; + const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId; + const matchName = payload.name && s.name === payload.name; + if (matchModel || matchName) { + // Если убираем активное оружие — снять модель из руки. + if (i === inv.activeIndex && this.scene3d?.weapons) { + try { this.scene3d.weapons.unequip(); } catch (e) {} + } + inv.removeSlot(i); + break; + } + } + } + return; + } + if (cmd === 'inventory.clear') { + const inv = this.scene3d?.inventory; + if (inv) { + try { this.scene3d?.weapons?.unequip(); } catch (e) {} + inv.clear(); + } + return; + } + // === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) === + if (cmd === 'room.set') { + // payload: { key, value } + if (payload && typeof payload.key === 'string') { + if (!this._roomState) this._roomState = {}; + const changed = this._roomState[payload.key] !== payload.value; + this._roomState[payload.key] = payload.value; + // Если есть Colyseus-комната — отправляем серверу (он + // обновит общее state; серверная схема — отдельная задача). + try { + this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', { + key: payload.key, value: payload.value, + }); + } catch (e) { /* ignore */ } + // Локально сразу рассылаем событие изменения всем скриптам. + if (changed) { + this.routeGlobalEvent('roomChange', { + key: payload.key, value: payload.value, + }); + } + } + return; + } + if (cmd === 'mp.sendTo') { + // payload: { sessionId, name, data } — адресное сообщение игроку. + if (payload) { + const mp = this.scene3d?._mpSync; + if (mp && mp.room && typeof mp.room.send === 'function') { + // С комнатой — через сервер (релей по sessionId). + try { + mp.room.send('scriptMessage', { + to: payload.sessionId, + name: payload.name, + data: payload.data, + }); + } catch (e) { /* ignore */ } + } else if (payload.sessionId === 'local') { + // Single-player: сообщение «себе» — доставляем сразу. + this.routeGlobalEvent('mpMessage', { + from: 'local', name: payload.name, data: payload.data, + }); + } + } + return; + } + // === Команды / Teams (Фаза 4.4) === + if (cmd === 'teams.create') { + // payload: { name, color } + if (payload && typeof payload.name === 'string' && payload.name) { + if (!this._teams) this._teams = new Map(); + this._teams.set(payload.name, { + name: payload.name, + color: typeof payload.color === 'string' ? payload.color : '#888888', + }); + } + return; + } + if (cmd === 'teams.remove') { + if (payload && this._teams) { + this._teams.delete(payload.name); + // Если игрок был в этой команде — сбрасываем. + if (this._localPlayerTeam === payload.name) { + this._localPlayerTeam = null; + } + } + return; + } + if (cmd === 'player.setTeam') { + // payload: { team } — null/'' убирает команду. + const t = payload?.team; + let applied = null; + if (t == null || t === '') { + this._localPlayerTeam = null; + applied = ''; + } else if (typeof t === 'string') { + // Назначаем только если команда существует. + if (this._teams?.has(t)) { + this._localPlayerTeam = t; + applied = t; + } else { + this._log('error', 'команда не создана: ' + t); + } + } + // С Colyseus-комнатой — синхронизируем команду на сервер, + // чтобы остальные игроки видели её в Player.team. + if (applied != null) { + try { + this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied }); + } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'player.setSpeed') { + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._speedMul = m; + } + return; + } + // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: + // game.player.equipAccessory(itemId) — надеть аксессуар прямо + // из скрипта игры (например выдать всем хеллоуинскую шапку при + // спавне). itemId — числовой id из rublox_items. + // Бэк фильтрует только published — на сервере ничего не настроишь. + if (cmd === 'player.equipAccessory') { + const player = this.scene3d?.player; + const itemId = Number(payload?.itemId); + if (!player || !Number.isFinite(itemId) || itemId <= 0) return; + (async () => { + try { + // Грузим item через публичный catalog (только published) + const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`); + if (!resp.ok) return; + const item = await resp.json(); + if (item && typeof player.equipAccessory === 'function') { + await player.equipAccessory(item); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] equipAccessory failed', e); + } + })(); + return; + } + if (cmd === 'player.unequipSlot') { + const player = this.scene3d?.player; + const slot = String(payload?.slot || ''); + if (player && slot && typeof player.unequipSlot === 'function') { + player.unequipSlot(slot); + } + return; + } + if (cmd === 'player.unequipAll') { + const player = this.scene3d?.player; + if (player && typeof player.unequipAll === 'function') { + player.unequipAll(); + } + return; + } + if (cmd === 'player.setJumpPower') { + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m; + } + return; + } + if (cmd === 'player.setGravityMul') { + // Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27). + // Не зависит от gravityDir — работает в обоих направлениях. + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._gravityMul = m; + } + return; + } + if (cmd === 'player.setShipMode') { + // GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). + const player = this.scene3d?.player; + if (player) player._shipMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setUfoMode') { + // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе. + const player = this.scene3d?.player; + if (player) player._ufoMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setWaveMode') { + // GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз). + const player = this.scene3d?.player; + if (player) player._waveMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setVy') { + // Прямое задание vy (для трамплинов, jump orb, boost-зон). + const player = this.scene3d?.player; + if (player) { + const v = Number(payload?.vy); + if (Number.isFinite(v)) player._vy = v; + } + return; + } + if (cmd === 'player.setRobotMode') { + // GD-гейммод Robot: variable-jump (высота = длительности удержания Space). + const player = this.scene3d?.player; + if (player) { + player._robotMode = !!payload?.enabled; + if (!player._robotMode) player._robotBoostLeft = 0; + } + return; + } + if (cmd === 'player.setDoubleJump') { + const player = this.scene3d?.player; + if (player) player._doubleJumpEnabled = !!payload?.enabled; + return; + } + if (cmd === 'player.playAnimation') { + const player = this.scene3d?.player; + if (player && typeof player.playEmote === 'function') { + const ok = player.playEmote(payload?.name); + if (!ok) { + this._log('error', 'playAnimation: эмоция не найдена — ' + + payload?.name + ' (доступно: wave, dance, cheer, sit)'); + } + } + return; + } + if (cmd === 'player.stopAnimation') { + const player = this.scene3d?.player; + if (player && typeof player.stopEmote === 'function') player.stopEmote(); + return; + } + if (cmd === 'player.setIceFriction') { + const player = this.scene3d?.player; + if (player) { + const v = Number(payload?.value); + if (Number.isFinite(v)) { + player._iceFriction = Math.max(0, Math.min(1, v)); + } + } + return; + } + if (cmd === 'player.setAutoRun') { + const player = this.scene3d?.player; + if (player) { + const s = Number(payload?.speed); + if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s); + } + return; + } + if (cmd === 'player.boostJump') { + const player = this.scene3d?.player; + if (player) { + const s = Number(payload?.strength); + if (Number.isFinite(s) && s > 0) { + // boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0) + const gDir = player._gravityDir || 1; + const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1); + player._vy = base * s * gDir; + } + } + return; + } + if (cmd === 'player.flipGravity') { + // Меняет направление гравитации (как blue orb в GD): +1 ↔ -1 + const player = this.scene3d?.player; + if (player) { + player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1; + // Сбрасываем "second jump used" чтобы после флипа доступен прыжок + player._doubleJumpUsed = false; + } + return; + } + if (cmd === 'player.setGravityDir') { + // Явно задать направление: dir=1 (вниз) или -1 (вверх). + const player = this.scene3d?.player; + if (player) { + const d = Number(payload?.dir); + if (d === 1 || d === -1) { + player._gravityDir = d; + player._doubleJumpUsed = false; + } + } + return; + } + if (cmd === 'player.getGravityDir') { + // Возвращает текущее значение через broadcast-style "reply" + // Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker + return; + } + // === HUD / Input / App === + if (cmd === 'hud.setVisible') { + try { + const v = !!payload?.visible; + this.scene3d?._setStdHudVisible?.(v); + } catch (e) {} + 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') { + try { + const mode = payload?.mode === 'ui' ? 'ui' : 'game'; + const player = this.scene3d?.player; + if (player?.setUiCursorMode) { + player.setUiCursorMode(mode === 'ui'); + if (mode === 'ui') { + try { document.exitPointerLock?.(); } catch (e) {} + // Подписываемся на mouse-события и транслируем в Worker. + if (player.setUiMouseMoveCallback) { + let lastMM = 0; + player.setUiMouseMoveCallback((x, y) => { + const now = performance.now(); + if (now - lastMM < 20) return; + lastMM = now; + this.routeGlobalEvent('mouseMove', { x, y }); + }); + } + if (player.setUiMouseDownCallback) { + player.setUiMouseDownCallback((x, y) => { + this.routeGlobalEvent('mouseDown', { x, y }); + }); + } + if (player.setUiMouseUpCallback) { + player.setUiMouseUpCallback((x, y) => { + this.routeGlobalEvent('mouseUp', { x, y }); + }); + } + } else if (player._requestPointerLockSafe) { + // Отписываемся при возврате в game-режим + if (player.setUiMouseMoveCallback) { + player.setUiMouseMoveCallback(null); + } + if (player.setUiMouseDownCallback) { + player.setUiMouseDownCallback(null); + } + if (player.setUiMouseUpCallback) { + player.setUiMouseUpCallback(null); + } + try { player._requestPointerLockSafe(); } catch (e) {} + } + // Сообщить редактору/плееру чтобы синхронизировать UI-state + try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {} + } + } catch (e) {} + return; + } + if (cmd === 'app.exit') { + try { + // На Майнкрафтия-плеере это шло на свой роут /kubikon3d + // (лента игр). В выделенном плеере (player.rublox.pro) + // таких роутов нет — переходим на ленту Рублокса. + window.location.assign(this._resolveExternalUrl('/kubikon3d')); + } catch (e) {} + return; + } + if (cmd === 'app.navigate') { + try { + const url = String(payload?.url || ''); + if (url) window.location.assign(this._resolveExternalUrl(url)); + } catch (e) {} + return; + } + // === Универсальное хранилище сейвов (game.save.*) === + if (cmd === 'save.get') { + this._saveGet(scriptId, payload); + return; + } + if (cmd === 'save.getAll') { + this._saveGetAll(scriptId, payload); + return; + } + if (cmd === 'save.set') { + this._saveSet(payload); + return; + } + if (cmd === 'save.merge') { + this._saveMerge(payload); + return; + } + if (cmd === 'save.leaderboard') { + this._saveLeaderboard(scriptId, payload); + return; + } + if (cmd === 'economy.reward') { + this._economyReward(scriptId, payload); + return; + } + if (cmd === 'economy.dailyCheck') { + this._economyDailyCheck(scriptId, payload); + return; + } + if (cmd === 'economy.getBalance') { + this._economyGetBalance(scriptId, payload); + return; + } + if (cmd === 'economy.spend') { + this._economySpend(scriptId, payload); + return; + } + if (cmd === 'camera.shake') { + const player = this.scene3d?.player; + if (player) { + const amp = Number(payload?.amp); + const dur = Number(payload?.dur); + if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) { + player._cameraShakeAmp = amp; + player._cameraShakeLeft = dur; + } + } + return; + } + // === Камера: FOV, привязка, катсцены (Фаза 5.7) === + if (cmd === 'camera.fov') { + this.scene3d?.player?.setCameraFov?.(payload?.degrees); + return; + } + if (cmd === 'camera.focus') { + // payload: { ref, distance, height } — следить за объектом. + const player = this.scene3d?.player; + if (player && payload && typeof payload.ref === 'string') { + const ref = payload.ref; + // getTarget резолвит позицию объекта каждый кадр. + const getTarget = () => { + const tgt = this._resolveTweenTarget(ref); + if (tgt && tgt.data) { + return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z }; + } + return null; + }; + player.cameraFocusOn(getTarget, { + distance: payload.distance, height: payload.height, + }); + } + return; + } + if (cmd === 'camera.cutscene') { + // payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration } + const player = this.scene3d?.player; + if (player && payload && Array.isArray(payload.points)) { + player.cameraCutscene( + payload.points, payload.lookAt, payload.segDuration, + // onDone — событие скрипту. + () => this.routeGlobalEvent('cutsceneDone', {}), + ); + } + return; + } + if (cmd === 'camera.reset') { + this.scene3d?.player?.cameraReset?.(); + return; + } + if (cmd === 'player.setInputBlocked') { + // Задача 13: блок управления (главное меню — игрок наблюдатель). + this.scene3d?.player?.setInputBlocked?.(!!payload?.blocked); + return; + } + if (cmd === 'player.setSkinVisible') { + const player = this.scene3d?.player; + if (player) { + const v = !!payload?.visible; + player._skinVisibleScripted = v; + // Применяем сразу — но также флаг будет применяться каждый + // кадр в _tick (на случай если меши ещё не загружены сейчас). + if (Array.isArray(player._modelMeshes)) { + for (const m of player._modelMeshes) { + try { m.setEnabled(v); } catch (e) {} + } + } + } + return; + } + if (cmd === 'player.setCameraMode') { + const player = this.scene3d?.player; + if (player && typeof payload?.mode === 'string') { + const valid = ['first', 'third', 'front', 'sideview']; + if (valid.includes(payload.mode)) { + player._cameraMode = payload.mode; + try { player._applyCameraMode?.(); } catch (e) {} + } + } + return; + } + if (cmd === 'player.setCrouch') { + const player = this.scene3d?.player; + if (player) { + const want = !!payload?.enabled; + player._scriptForcedCrouch = want; + if (want !== player._crouching) { + player._crouching = want; + const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL; + // КРИТИЧНО: _pos — центр капсулы. При смене HALF_H + // центр надо сдвинуть на ту же дельту, иначе «низ ног» + // (_pos.y - HALF_H) меняется и персонажа подкидывает + // вверх при приседе. Сдвигаем — низ ног остаётся на месте. + const dH = newHalfH - player.HALF_H; + player.HALF_H = newHalfH; + if (player._pos) player._pos.y += dH; + } + } + return; + } + if (cmd === 'player.setFacing') { + // Развернуть модель игрока на угол yaw (радианы). Полезно + // в кат-сценах, когда игрок стоит лицом куда нужно. + const player = this.scene3d?.player; + if (player) { + const yaw = Number(payload?.yaw); + if (Number.isFinite(yaw)) { + player._modelYaw = yaw; + if (player._modelRoot) player._modelRoot.rotation.y = yaw; + } + } + return; + } + if (cmd === 'player.emote') { + // Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint). + // Работает только для R15-скинов. + const player = this.scene3d?.player; + if (player && typeof player.playEmote === 'function') { + const name = payload?.name; + if (typeof name === 'string') { + try { player.playEmote(name); } catch (e) { /* ignore */ } + } + } + return; + } + if (cmd === 'player.stopEmote') { + const player = this.scene3d?.player; + if (player && typeof player.stopEmote === 'function') { + try { player.stopEmote(); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') { + // Делегируем в scene3d — у него есть колбэки для UI/API + const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart' + : cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit']; + if (typeof fn === 'function') { + try { fn.call(this.scene3d); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'self.move') { + this._applySelfMove(payload); + return; + } + if (cmd === 'scene.rotate') { + try { + const ry = Number(payload?.rotationY); + if (!Number.isFinite(ry)) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.rotationY = ry; + if (data.mesh?.rotation) { + data.mesh.rotation.y = ry; + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у, + // только rotationY обновился, для скрипта это прозрачно. + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.rotate failed', e); + } + return; + } + if (cmd === 'scene.setRotation') { + try { + const rx = Number(payload?.rx); + const ry = Number(payload?.ry); + const rz = Number(payload?.rz); + if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.rotationX = rx; + data.rotationY = ry; + data.rotationZ = rz; + if (data.mesh?.rotation) { + data.mesh.rotation.x = rx; + data.mesh.rotation.y = ry; + data.mesh.rotation.z = rz; + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + } catch (e) { + console.warn('[GameRuntime] scene.setRotation failed', e); + } + return; + } + if (cmd === 'scene.setCollide') { + try { + const canCollide = !!payload?.canCollide; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.canCollide = canCollide; + if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide; + this.scene3d?.physics?.setSpatialDirty?.(); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setCollide failed', e); + } + return; + } + if (cmd === 'scene.setColor') { + try { + const color = payload?.color; + if (typeof color !== 'string') return; + // Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager. + const ref = payload?.id; + if (typeof ref === 'string' && ref.startsWith('block:')) { + const parts = ref.slice(6).split(',').map(Number); + if (parts.length === 3 && parts.every(Number.isFinite)) { + this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color); + } + return; + } + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.color = color; + if (data.mesh?.material) { + const c = Color3.FromHexString(color); + data.mesh.material.diffuseColor = c; + // Если материал neon — обновляем emissive тоже + if (data.material === 'neon') { + data.mesh.material.emissiveColor = c; + } + if (data.material === 'studs') { + data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45); + } + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setColor failed', e); + } + return; + } + if (cmd === 'scene.setOpacity') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity }); + } catch (e) { + console.warn('[GameRuntime] scene.setOpacity failed', e); + } + return; + } + if (cmd === 'scene.setScale') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) { + pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz }); + } + } catch (e) { + console.warn('[GameRuntime] scene.setScale failed', e); + } + return; + } + if (cmd === 'scene.setMaterial') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) pm.updateInstance(id, { material: payload.material }); + } catch (e) { + console.warn('[GameRuntime] scene.setMaterial failed', e); + } + return; + } + if (cmd === 'scene.clone') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id == null || !pm) return; + const src = pm.instances.get(id); + if (!src) return; + const newId = pm.addInstance(src.type, { + x: (src.x || 0) + (Number(payload.dx) || 0), + y: (src.y || 0) + (Number(payload.dy) || 0), + z: (src.z || 0) + (Number(payload.dz) || 0), + sx: src.sx, sy: src.sy, sz: src.sz, + color: src.color, material: src.material, + rotationY: src.rotationY, + }); + if (newId != null) { + if (!this._localToReal) this._localToReal = new Map(); + this._localToReal.set(payload.newRef, 'primitive:' + newId); + this.scheduleSceneSnapshot(); + } + } catch (e) { + console.warn('[GameRuntime] scene.clone failed', e); + } + return; + } + if (cmd === 'self.registerInteract') { + try { + const t = payload?.target; + if (!t) return; + // ref объекта-носителя скрипта + const ref = (t.kind && (t.ref ?? t.id) != null) + ? (t.kind + ':' + (t.ref ?? t.id)) : null; + if (!ref) return; + // не дублируем — один объект = одна запись + if (!this._interactables.some(it => it.ref === ref)) { + this._interactables.push({ + ref, + target: t, + text: payload.text || 'Взаимодействовать', + distance: Number(payload.distance) || 4, + key: payload.key || 'e', + holdDuration: Number(payload.holdDuration) || 0, + isInst: false, + }); + } + } catch (e) { + console.warn('[GameRuntime] self.registerInteract failed', e); + } + return; + } + if (cmd === 'inst.registerInteract') { + try { + const ref = payload?.ref; + if (ref && !this._interactables.some(it => it.ref === ref)) { + this._interactables.push({ + ref, target: null, + text: payload.text || 'Взаимодействовать', + distance: Number(payload.distance) || 4, + key: payload.key || 'e', + holdDuration: Number(payload.holdDuration) || 0, + isInst: true, + }); + } + } catch (e) { /* ignore */ } + return; + } + if (cmd === 'scene.setLabel') { + try { + const ref = payload?.ref; + const text = payload?.text; + if (typeof ref !== 'string') return; + // ленивое создание менеджера меток + if (!this.scene3d._labelManager) { + const { LabelManager } = require('./LabelManager'); + this.scene3d._labelManager = new LabelManager(this.scene3d.scene); + } + const lm = this.scene3d._labelManager; + // резолвим меш объекта (примитив или модель) + const tgt = this._resolveTweenTarget(ref); + const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); + if (mesh) { + lm.setLabel(ref, mesh, text, payload?.opts || {}); + } + } catch (e) { + console.warn('[GameRuntime] scene.setLabel failed', e); + } + return; + } + if (cmd === 'scene.clearLabel') { + try { + const lm = this.scene3d?._labelManager; + if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref); + } catch (e) { + console.warn('[GameRuntime] scene.clearLabel failed', e); + } + return; + } + if (cmd === 'scene.setData') { + try { + const { ref, key, value } = payload || {}; + if (typeof ref !== 'string' || typeof key !== 'string') return; + if (!this._objectData[ref]) this._objectData[ref] = {}; + this._objectData[ref][key] = value; + this.scheduleDataSnapshot(); + } catch (e) { + console.warn('[GameRuntime] scene.setData failed', e); + } + return; + } + // === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged === + // Теги хранятся как массив в _objectData[ref].__tags — переиспользуем + // готовый канал dataSnapshot, отдельная синхронизация не нужна. + if (cmd === 'scene.tag' || cmd === 'scene.untag') { + try { + const { ref, tag } = payload || {}; + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + if (!this._objectData[ref]) this._objectData[ref] = {}; + const cur = Array.isArray(this._objectData[ref].__tags) + ? this._objectData[ref].__tags : []; + this._objectData[ref].__tags = cmd === 'scene.tag' + ? (cur.includes(tag) ? cur : [...cur, tag]) + : cur.filter(t => t !== tag); + this.scheduleDataSnapshot(); + } catch (e) { + console.warn('[GameRuntime] scene.tag failed', e); + } + return; + } + // === Collision groups (Фаза 5.9) — проходимость объекта/группы === + // physics.passThrough — игрок проходит сквозь объект (объект виден). + // target: ref одного объекта ИЛИ тег (тогда применяется ко всей + // группе объектов с этим тегом — теги = collision groups). + if (cmd === 'physics.passThrough') { + try { + const { target, on } = payload || {}; + if (typeof target !== 'string' || !target) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + // canCollide = !on (passThrough=true → коллизия выключена). + const canCollide = !on; + // Собираем список ref: либо один объект, либо все с тегом. + let refs; + if (target.indexOf(':') >= 0) { + refs = [target]; // похоже на ref объекта + } else { + // Тег — все объекты с ним. + refs = []; + for (const r of Object.keys(this._objectData)) { + const bag = this._objectData[r]; + if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) { + refs.push(r); + } + } + } + for (const r of refs) { + const rid = this._resolvePrimitiveId(r); + if (rid != null) pm.updateInstance(rid, { canCollide }); + } + // Сбрасываем кэш spatial-grid физики — иначе grid до 50мс + // держит старое состояние, и при возврате твёрдости (on=false) + // UNSTUCK не видит стену, игрок застревает в ней. + this.scene3d?.physics?.invalidateSpatialGrid?.(); + } catch (e) { + console.warn('[GameRuntime] physics.passThrough failed', e); + } + return; + } + if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + const dm = this.scene3d?.dynamics; + if (id == null || !pm || !dm) return; + const data = pm.instances.get(id); + if (!data) return; + const isImpulse = cmd === 'physics.applyImpulse'; + const vx = isImpulse ? payload.ix : payload.vx; + const vy = isImpulse ? payload.iy : payload.vy; + const vz = isImpulse ? payload.iz : payload.vz; + const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set'); + if (!ok) { + this._log('error', cmd + ': объект закреплён (anchored) — ' + + 'физика работает только для незакреплённых объектов'); + } + } catch (e) { + console.warn('[GameRuntime] ' + cmd + ' failed', e); + } + return; + } + if (cmd === 'physics.explode') { + try { + const { x, y, z, radius, damage, force } = payload || {}; + const r = Number(radius) || 3; + // визуальный эффект взрыва + this._handleCommand(scriptId, 'scene.particles', { + type: 'explosion', position: { x, y, z }, + duration: 1.2, count: 2, color: null, + }); + // урон игроку если в радиусе + const player = this.scene3d?.player; + if (player && Number(damage) > 0) { + const pp = player._pos || player.position; + if (pp) { + const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z; + if (dx*dx + dy*dy + dz*dz <= r*r) { + try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {} + } + } + } + // убиваем мобов в радиусе + const zm = this.scene3d?.zombieManager; + if (zm && typeof zm.getMobsSnapshot === 'function') { + const mobs = zm.getMobsSnapshot(); + for (const m of mobs) { + const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z; + if (dx*dx + dy*dy + dz*dz <= r*r) { + try { zm.killById(m.id); } catch (e) {} + } + } + } + } catch (e) { + console.warn('[GameRuntime] physics.explode failed', e); + } + return; + } + if (cmd === 'tween.start') { + this._startTween(scriptId, payload); + return; + } + if (cmd === 'tween.cancel') { + const tid = payload?.tweenId; + if (tid != null) { + const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId); + if (i >= 0) this._tweens.splice(i, 1); + } + return; + } + if (cmd === 'scene.setTexture') { + // Установить динамическую текстуру примитива из dataURL. + // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). + try { + const dataUrl = payload?.dataUrl; + if (typeof dataUrl !== 'string') return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + if (rid != null) pm.setTexture(rid, dataUrl); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setTexture failed', e); + } + return; + } + // === AUDIO: GD-музыка и SFX === + if (cmd === 'audio.playSfx') { + try { + const am = this.scene3d?.gameAudioManager; + if (am && payload?.name) am.playSfx(payload.name); + } catch (e) { + console.warn('[GameRuntime] audio.playSfx failed', e); + } + return; + } + if (cmd === 'audio.playMusic') { + try { + const am = this.scene3d?.gameAudioManager; + if (am && payload?.trackId) am.playMusic(payload.trackId); + } catch (e) { + console.warn('[GameRuntime] audio.playMusic failed', e); + } + return; + } + if (cmd === 'audio.stopMusic') { + try { + const am = this.scene3d?.gameAudioManager; + if (am) am.stopMusic(); + } catch (e) { + console.warn('[GameRuntime] audio.stopMusic failed', e); + } + return; + } + if (cmd === 'audio.setMuted') { + try { + const am = this.scene3d?.gameAudioManager; + if (am) am.setMuted(!!payload?.muted); + } catch (e) { + console.warn('[GameRuntime] audio.setMuted failed', e); + } + return; + } + if (cmd === 'scene.setVisible') { + try { + const kind = payload?.kind; + const id = payload?.id; + const visible = !!payload?.visible; + if (id == null) return; + if (kind === 'primitive') { + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.visible = visible; + if (data.mesh) data.mesh.setEnabled(visible); + } + } else if (kind === 'model') { + const mm = this.scene3d?.modelManager; + if (!mm) return; + let data = mm.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = mm.instances.get(n); + } + if (data) { + data.visible = visible; + if (data.rootMesh) data.rootMesh.setEnabled(visible); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setVisible failed', e); + } + return; + } + if (cmd === 'scene.setFolderYaw') { + try { + const fm = this.scene3d?.folderManager; + if (!fm) return; + const name = payload?.folderName; + const angle = Number(payload?.angle); + const pivot = payload?.pivot; + if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return; + const folder = fm.findByName(name); + if (!folder) return; + fm.setFolderYawY(folder.id, angle, pivot); + this.scheduleSceneSnapshot(); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setFolderYaw failed', e); + } + return; + } + if (cmd === 'self.delete') { + this._applySelfDelete(payload); + return; + } + if (cmd === 'scene.spawn') { + this._applySceneSpawn(scriptId, payload); + return; + } + if (cmd === 'scene.delete') { + this._applySceneDelete(payload); + return; + } + if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') { + // Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует + if (this._onHud) { + try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'sound.play') { + this._playSound(payload); + return; + } + if (cmd === 'scene.particles') { + this._spawnParticles(payload); + return; + } + if (cmd === 'mob.kill') { + try { + const id = Number(payload?.id); + if (Number.isFinite(id) && this.scene3d?.zombieManager) { + this.scene3d.zombieManager.killById(id); + } + } catch (e) { + this._log('error', 'mob.kill failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.update') { + // payload: { id, patch } + try { + let id = payload?.id; + const patch = payload?.patch || {}; + if (typeof id !== 'string') return; + // Резолвим локальный ref (тот что вернул gui.create) → реальный id + if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); + this.scene3d?.updateGuiElement?.(id, patch); + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.update failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.create') { + try { + const type = payload?.type; + const opts = { ...(payload?.opts || {}) }; + const localRef = payload?.localRef; + if (typeof type !== 'string') return; + // Помечаем как созданный скриптом — чтобы НЕ попал в + // сериализацию проекта (иначе автосейв сохранит его в БД + // и после Stop он «вернётся» из сохранённого проекта). + opts._scriptCreated = true; + // Резолвим parentId если это локальный ref из предыдущего create + if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) { + opts.parentId = this._guiLocalToReal.get(opts.parentId); + } + const realId = this.scene3d?.createGuiElement?.(type, opts); + if (realId && localRef) { + if (!this._guiLocalToReal) this._guiLocalToReal = new Map(); + if (!this._guiRealToLocal) this._guiRealToLocal = new Map(); + this._guiLocalToReal.set(localRef, realId); + this._guiRealToLocal.set(realId, localRef); + } + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.create failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.remove') { + try { + let id = payload?.id; + if (typeof id !== 'string') return; + const localId = id; + if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); + this.scene3d?.removeGuiElement?.(id); + // Чистим mapping чтобы не утекало + if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId); + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.remove failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'broadcast') { + // Рассылаем именованное сообщение всем sandbox'ам + this.routeGlobalEvent('message', { + name: String(payload?.name || ''), + data: payload?.data ?? null, + }); + return; + } + if (cmd === 'player.crosshair') { + const type = String(payload?.type || 'none').toLowerCase(); + try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ } + if (this._onCrosshair) { + try { this._onCrosshair(type); } catch (e) { /* ignore */ } + } + return; + } + // === Задача 07: скины игрока === + if (cmd === 'player.setSkin') { + const player = this.scene3d?.player; + const slug = payload?.slug; + if (player && typeof slug === 'string' && slug) { + const typeId = this._resolveSkinTypeId(slug); + // Помечаем доступным (setSkin неявно разблокирует). + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this._skinState.current = slug; + // Асинхронная перезагрузка модели; по завершении шлём skinChanged. + Promise.resolve(player.reloadSkin?.(typeId)).then(() => { + this.routeGlobalEvent?.('skinChanged', { slug }); + try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {} + }).catch((e) => { + this._log('error', 'setSkin failed: ' + (e?.message || e)); + }); + } + return; + } + if (cmd === 'player.unlockSkin') { + const slug = payload?.slug; + if (typeof slug === 'string' && slug) { + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this.routeGlobalEvent?.('skinUnlocked', { slug }); + } + return; + } + if (cmd === 'player.openSkinShop') { + this._ensureSkinState(); + try { this.scene3d?._openSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.closeSkinShop') { + try { this.scene3d?._closeSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.setSkinCoins') { + this._ensureSkinState(); + const n = Number(payload?.amount); + if (Number.isFinite(n)) { + this._skinState.coins = Math.max(0, Math.floor(n)); + this._broadcastSkinsSnapshot(); + } + return; + } + // Покупка скина из встроенного магазина (намерение от React-оверлея + // или из скрипта). Списывает локальные рублики, разблокирует, надевает. + if (cmd === 'player.buySkin') { + this._ensureSkinState(); + const slug = payload?.slug; + const price = Number(payload?.price) || 0; + if (typeof slug !== 'string' || !slug) return; + const st = this._skinState; + const owned = st.unlocked.has(slug); + if (owned) { + // Уже куплен — просто надеть. + this._handleCommand(scriptId, 'player.setSkin', { slug }); + return; + } + if (st.coins < price) { + // Не хватает — сообщаем оверлею. + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {} + return; + } + st.coins -= price; + st.unlocked.add(slug); + this._handleCommand(scriptId, 'player.setSkin', { slug }); + this._broadcastSkinsSnapshot(); + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {} + return; + } + // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock === + if (cmd === 'player.setCameraZoom') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoom === 'function') { + try { player.setCameraZoom(payload?.distance); } catch (e) {} + } + return; + } + if (cmd === 'player.setCameraZoomLimits') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoomLimits === 'function') { + try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {} + } + return; + } + if (cmd === 'player.setShiftLock') { + const player = this.scene3d?.player; + if (player && typeof player.setShiftLock === 'function') { + try { player.setShiftLock(payload?.on); } catch (e) {} + } + return; + } + // === Задача 02: environment API === + if (cmd === 'environment.setSkyColor') { + try { + const hex = String(payload?.color || ''); + const scene = this.scene3d?.scene; + if (scene && hex) { + // Парсим #rrggbb → clearColor + const m = hex.match(/^#?([0-9a-f]{6})$/i); + if (m) { + const n = parseInt(m[1], 16); + const r = ((n >> 16) & 0xff) / 255; + const g = ((n >> 8) & 0xff) / 255; + const b = (n & 0xff) / 255; + if (scene.clearColor) { + scene.clearColor.r = r; + scene.clearColor.g = g; + scene.clearColor.b = b; + scene.clearColor.a = 1; + } + } + } + } catch (e) { + this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'environment.setFog') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setFog === 'function') { + env.setFog(payload?.enabled, payload?.color, payload?.density); + } + } catch (e) {} + return; + } + if (cmd === 'environment.setTimeOfDay') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setTimeOfDay === 'function') { + env.setTimeOfDay(payload?.hours); + } + } catch (e) {} + return; + } + // === Задача 03: GUI tween === + if (cmd === 'gui.tween') { + try { + const guiId = payload?.id; + if (typeof guiId !== 'string' || !guiId) return; + const gm = this.scene3d?.guiManager; + if (!gm) return; + // Резолв localRef → realId если есть + let realId = guiId; + if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId); + const el = gm.elements?.find(e => e.id === realId); + if (!el) return; + if (!this._guiTweens) this._guiTweens = []; + // Снимок начальных значений по тем ключам что есть в props + const props = payload.props || {}; + const propKeys = Object.keys(props); + // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id, + // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей. + // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый. + for (let j = this._guiTweens.length - 1; j >= 0; j--) { + const old = this._guiTweens[j]; + if (old.realId !== realId) continue; + const oldKeys = Object.keys(old.target); + const overlap = oldKeys.some(k => propKeys.includes(k)); + if (overlap) this._guiTweens.splice(j, 1); + } + const start = {}; + for (const k of propKeys) { + if (k in el) start[k] = el[k]; + else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1); + } + this._guiTweens.push({ + tweenId: payload.tweenId, + scriptId, + realId, + start, target: { ...props }, + elapsed: 0, + duration: Math.max(0.001, Number(payload.duration) || 0.5), + delay: Math.max(0, Number(payload.delay) || 0), + easing: payload.easing || 'ease', + repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0, + reverses: !!payload.reverses, + iter: 0, + dir: 1, // 1 = вперёд, -1 = обратно (для reverses) + }); + } catch (e) { + this._log('error', 'gui.tween failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.cancelTween') { + const tid = payload?.tweenId; + if (tid != null && this._guiTweens) { + const i = this._guiTweens.findIndex(t => t.tweenId === tid); + if (i >= 0) this._guiTweens.splice(i, 1); + } + return; + } + // === Задача 04: модал-сцены === + if (cmd === 'modal.open') { + try { + const mm = this.scene3d?.modalManager; + if (!mm) return; + // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно + const opts = { ...(payload?.opts || {}) }; + if (Array.isArray(opts.spotlights)) { + opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r); + } + if (opts.cameraOverride && opts.cameraOverride.target) { + opts.cameraOverride = { + ...opts.cameraOverride, + target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target, + }; + } + const modalId = mm.open(opts); + // Подписка чтобы автоматически слать tweenDone-стиль событий + // на конкретный скрипт (тот кто открыл) — для onClose. + if (!mm._runtimeBoundOnClose) { + mm._runtimeBoundOnClose = true; + mm.onClose((closedId) => { + // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn + this.routeGlobalEvent?.('modalClosed', { id: closedId }); + }); + } + // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open) + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && payload?.replyId != null) { + sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId }); + } + } catch (e) { + this._log('error', 'modal.open failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'modal.close') { + try { + const mm = this.scene3d?.modalManager; + mm?.close?.(payload?.modalId); + } catch (e) {} + return; + } + if (cmd === 'modal.update') { + try { + const mm = this.scene3d?.modalManager; + mm?.update?.(payload?.modalId, payload?.patch); + } catch (e) {} + return; + } + // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) === + if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { + // Резолв ref → primitiveId. + // Worker может прислать ref сразу после game.scene.spawn — до + // того как main spawn'нул примитив и обновил _localToReal. + // Откладываем команду до резолва. + let ref = payload?.ref; + if (typeof ref === 'string' && ref.includes('_local_') + && !this._localToReal?.has(ref)) { + this._pendingResolveQueue = this._pendingResolveQueue || []; + this._pendingResolveQueue.push({ cmd, payload, scriptId }); + return; + } + try { + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + let id = null; + if (typeof ref === 'string' && ref.startsWith('primitive:')) { + id = Number(ref.slice('primitive:'.length)); + } else if (Number.isFinite(ref)) { + id = Number(ref); + } + if (!Number.isFinite(id) || id == null) return; + const data = this.scene3d?.primitiveManager?.instances?.get(id); + if (!data || data.type !== 'billboard') return; + const mgr = this.scene3d?.billboardUiManager; + if (!mgr) return; + + if (cmd === 'billboard.set') { + mgr.applyToMesh(data, { + template: payload.template || data.billboard?.template || 'shop-item', + face: payload.face || data.billboard?.face || 'camera', + content: payload.content || data.billboard?.content, + elements: payload.elements || data.billboard?.elements, + }); + this.scheduleSceneSnapshot?.(); + } else if (cmd === 'billboard.update') { + // 2 формы: с elementId (точечно) или без (patch content) + if (typeof payload.elementId === 'string') { + mgr.update(data, payload.elementId, payload.patch || {}); + } else { + mgr.update(data, payload.patch || {}); + } + this.scheduleSceneSnapshot?.(); + } else if (cmd === 'billboard.onClick') { + const buttonId = String(payload.buttonId || 'buy'); + const realRef = 'primitive:' + id; + mgr.onClick(data, buttonId, () => { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && typeof sb.sendGlobalEvent === 'function') { + // billboardClick роутится в worker'е через globalEvent-ветку + // (см. ScriptSandboxWorker.js cmd === 'globalEvent'). + sb.sendGlobalEvent({ + type: 'billboardClick', + ref: realRef, + button: buttonId, + }); + } + }); + } + } catch (e) { + this._log('error', cmd + ' failed: ' + (e?.message || e)); + } + return; + } + // eslint-disable-next-line no-console + console.warn('[GameRuntime] unknown cmd', cmd); + } + + /** + * Создать объект из скрипта. + * payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... } + * После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id. + */ + _applySceneSpawn(scriptId, payload) { + if (!payload) return; + const { kind, subType, ref } = payload; + if (!this._localToReal) this._localToReal = new Map(); + try { + if (kind === 'block') { + // color — для окрашиваемых блоков (studs-block); иначе игнорируется. + this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color); + // Для блоков ref детерминированный, но запоминаем — чтобы при + // Stop удалить заспавненные скриптом блоки (см. stop()). + if (ref) this._localToReal.set(ref, ref); + this.scheduleSceneSnapshot(); + } else if (kind === 'model') { + // addInstance возвращает Promise (async из-за GLB) + const opts = payload; + const p = this.scene3d?.modelManager?.addInstance( + subType, opts.x, opts.y, opts.z, opts.rotationY || 0 + ); + Promise.resolve(p).then((instId) => { + if (instId == null) return; + if (opts.name) { + const data = this.scene3d?.modelManager?.instances?.get(instId); + if (data) data.name = opts.name; + } + this._localToReal.set(ref, 'model:' + instId); + this._notifySpawnResolved(ref, 'model:' + instId); + this._drainPendingResolveQueue?.(ref); + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn model failed: ' + (err?.message || err)); + }); + } else if (kind === 'userModel') { + // Пользовательская воксельная модель: subType = 'user:'. + // addInstance возвращает Promise. + const opts = payload; + const p = this.scene3d?.userModelManager?.addInstance( + subType, opts.x, opts.y, opts.z, opts.rotationY || 0, + (opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {}, + ); + Promise.resolve(p).then((instId) => { + if (instId == null) return; + if (opts.name) { + const data = this.scene3d?.userModelManager?.instances?.get(instId); + if (data) data.name = opts.name; + } + this._localToReal.set(ref, 'usermodel:' + instId); + this._notifySpawnResolved(ref, 'usermodel:' + instId); + this._drainPendingResolveQueue?.(ref); + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn user model failed: ' + (err?.message || err)); + }); + } else if (kind === 'primitive') { + const opts = payload; + const id = this.scene3d?.primitiveManager?.addInstance(subType, { + x: opts.x, y: opts.y, z: opts.z, + sx: opts.sx, sy: opts.sy, sz: opts.sz, + color: opts.color, material: opts.material, + rotationY: opts.rotationY, + name: opts.name, + brightness: opts.brightness, range: opts.range, + effect: opts.effect, + // textureAsset — картинка из ассетов проекта на грани. + ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}), + // anchored:false → объект падает (физика unanchored). + // canCollide:false → проходимый (зона-триггер). + ...(opts.anchored != null ? { anchored: opts.anchored } : {}), + ...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}), + ...(opts.visible != null ? { visible: opts.visible } : {}), + }); + if (id != null) { + this._localToReal.set(ref, 'primitive:' + id); + this._notifySpawnResolved(ref, 'primitive:' + id); + this._drainPendingResolveQueue?.(ref); + const data = this.scene3d?.primitiveManager?.instances?.get(id); + if (data) { + // Помечаем как заспавненный скриптом — движок шлёт + // для таких onPlayerTouch (нужно для «поймай объект»). + data._scriptSpawned = true; + // Если unanchored — регистрируем в физике на лету, + // иначе он не падает (start() уже отработал). + if (opts.anchored === false) { + this.scene3d?.dynamics?.registerPrimitive(data); + } + } + this.scheduleSceneSnapshot(); + } + } else if (kind === 'vehicle') { + const opts = payload; + const p = this.scene3d?.vehicleManager?.spawn({ + model: opts.model || 'car-sedan', color: opts.color, name: opts.name, + params: opts.params, x: opts.x, y: opts.y, z: opts.z, + rotationY: opts.rotationY || 0, ref, + }); + Promise.resolve(p).then((vid) => { + if (vid == null) return; + const realRef = 'vehicle:' + vid; + this._localToReal.set(ref, realRef); + this._notifySpawnResolved(ref, realRef); + const veh = this.scene3d?.vehicleManager?.getById?.(vid); + if (veh && !this._interactables.some(it => it.ref === realRef)) { + this._interactables.push({ + ref: realRef, target: null, + text: 'Enter', objectName: veh.name, + distance: Math.max(4, veh.half.d + 2), key: 'f', + holdDuration: 0.4, isInst: true, isVehicle: true, + }); + } + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn vehicle failed: ' + (err?.message || err)); + }); + } + } catch (e) { + this._log('error', 'scene.spawn failed: ' + (e?.message || e)); + } + } + + /** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */ + _applySceneDelete(payload) { + if (!payload?.ref) return; + let ref = payload.ref; + // Резолвим локальный ref → реальный + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + // Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась + // (асинхронная загрузка GLB). Откладываем удаление: оно сработает + // в _notifySpawnResolved, когда реальный id появится. Без этого + // removeInstance(NaN) промахивался и объект «осиротевал» на сцене. + if (ref.indexOf('_local_') >= 0) { + if (!this._pendingDeletes) this._pendingDeletes = new Set(); + this._pendingDeletes.add(ref); + return; + } + try { + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const rest = ref.slice(colon + 1); + if (kind === 'block') { + const [xs, ys, zs] = rest.split(','); + this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); + } else if (kind === 'model') { + this.scene3d?.modelManager?.removeInstance(Number(rest)); + } else if (kind === 'primitive') { + this.scene3d?.primitiveManager?.removeInstance(Number(rest)); + } + // Удалили — снимаем mapping + for (const [k, v] of (this._localToReal || new Map()).entries()) { + if (v === ref) this._localToReal.delete(k); + } + this.scheduleSceneSnapshot(); + } catch (e) { + this._log('error', 'scene.delete failed: ' + (e?.message || e)); + } + } + + /** + * Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре. + * Делается отложенно чтобы при массовом spawn (например в onKey) отправить + * snapshot один раз, а не N раз. + */ + scheduleSceneSnapshot() { + if (this._snapshotPending) return; + this._snapshotPending = true; + // microtask — следующий кадр render-loop'а почти наверняка + Promise.resolve().then(() => { + this._snapshotPending = false; + this._broadcastSceneSnapshot(); + }); + } + + /** Рассылка snapshot всем sandbox'ам. */ + _broadcastSceneSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + const snap = this._buildSceneSnapshot(); + for (const sb of this.sandboxes) { + sb.sendSceneSnapshot(snap); + } + } + + /** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */ + scheduleGuiSnapshot() { + if (this._guiSnapshotPending) return; + this._guiSnapshotPending = true; + Promise.resolve().then(() => { + this._guiSnapshotPending = false; + this._broadcastGuiSnapshot(); + }); + } + + _broadcastGuiSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + const snap = this._buildGuiSnapshot(); + for (const sb of this.sandboxes) { + sb.sendGuiSnapshot(snap); + } + } + + /** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */ + scheduleDataSnapshot() { + if (this._dataSnapshotPending) return; + this._dataSnapshotPending = true; + Promise.resolve().then(() => { + this._dataSnapshotPending = false; + this._broadcastDataSnapshot(); + }); + } + + _broadcastDataSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + for (const sb of this.sandboxes) { + sb.sendDataSnapshot(this._objectData); + } + } + + _buildGuiSnapshot() { + const list = this.scene3d?.getGuiElements?.() || []; + return list.map(g => ({ + id: g.id, type: g.type, name: g.name, + parentId: g.parentId || null, + x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor, + visible: g.visible !== false, + text: g.text, textColor: g.textColor, textSize: g.textSize, + bgColor: g.bgColor, bgOpacity: g.bgOpacity, + imageUrl: g.imageUrl, + placeholder: g.placeholder, + })); + } + + /** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */ + _buildSceneSnapshot() { + const blocks = []; + const models = []; + const primitives = []; + const s = this.scene3d; + if (s?.blockManager) { + for (const proxy of s.blockManager.blocks.values()) { + const md = proxy.metadata; + if (!md?.isBlock) continue; + blocks.push({ + ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ, + type: md.blockTypeId, + x: md.gridX, y: md.gridY, z: md.gridZ, + }); + } + } + if (s?.modelManager) { + for (const data of s.modelManager.instances.values()) { + models.push({ + ref: 'model:' + data.instanceId, + type: data.modelTypeId, + x: data.x, y: data.y, z: data.z, + name: data.name || null, + }); + } + } + if (s?.primitiveManager) { + for (const data of s.primitiveManager.instances.values()) { + primitives.push({ + ref: 'primitive:' + data.id, + type: data.type, + x: data.x, y: data.y, z: data.z, + // размеры/поворот нужны для game.physics.raycast (ray vs AABB) + sx: data.sx != null ? data.sx : 1, + sy: data.sy != null ? data.sy : 1, + sz: data.sz != null ? data.sz : 1, + rotationY: data.rotationY || 0, + visible: data.visible !== false, + name: data.name || null, + }); + } + } + return { blocks, models, primitives }; + } + + _applySelfMove(payload) { + if (!payload || !payload.target) return; + const t = payload.target; + const { x, y, z } = payload; + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; + try { + if (t.kind === 'model') { + let id = t.id ?? t.ref; + const mm = this.scene3d?.modelManager; + if (!mm) return; + // Локальный ref '_local_N' от scene.spawn → реальный id. + if (typeof id === 'string' && id.indexOf('_local_') === 0 + && this._localToReal) { + const real = this._localToReal.get('model:' + id); + if (real) { + const c2 = real.indexOf(':'); + id = c2 >= 0 ? real.slice(c2 + 1) : real; + } + } + let data = mm.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = mm.instances.get(n); + } + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.rootMesh?.position) { + data.rootMesh.position.set(x, y, z); + if (data._worldMatrixFrozen) { + try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {} + if (Array.isArray(data.meshes)) { + for (const m of data.meshes) { + try { m?.unfreezeWorldMatrix?.(); } catch (e) {} + } + } + data._worldMatrixFrozen = false; + } + } + } + } else if (t.kind === 'primitive') { + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + // _resolvePrimitiveId умеет и числовой id, и локальный + // ref '_local_N' (от scene.spawn) — без этого scene.move + // не находит объект, заспавненный скриптом. + const rid = this._resolvePrimitiveId(t.id ?? t.ref); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.mesh?.position) { + data.mesh.position.set(x, y, z); + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + } else if (t.kind === 'userModel') { + // userModel-инстанс: отдельная нода (rootNode), не thin-instance. + // Двигаем root.position + обновляем data.x/y/z. + const id = t.id ?? t.ref; + const um = this.scene3d?.userModelManager; + if (!um) return; + let data = um.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = um.instances.get(n); + } + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.rootNode?.position) { + data.rootNode.position.set(x, y, z); + } + } + } + // НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё + // равно стейл (sandbox использует findOne и сам не зависит от + // координат в snapshot). Иначе при анимации платформ (десятки + // scene.move в секунду) шлём весь snapshot 11000+ объектов в worker + // через структурный postMessage — это может стоить сотни мс. + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] self.move failed', e); + } + } + + _applySelfDelete(payload) { + if (!payload || !payload.target) return; + const t = payload.target; + try { + if (t.kind === 'block') { + const r = t.ref || t; + this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z); + } else if (t.kind === 'model') { + const id = t.id ?? t.ref; + this.scene3d?.modelManager?.removeInstance(id); + } else if (t.kind === 'primitive') { + const id = t.id ?? t.ref; + this.scene3d?.primitiveManager?.removeInstance(id); + } + this.scheduleSceneSnapshot(); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] self.delete failed', e); + } + } + + _log(level, text) { + if (this._onLog) { + try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } + } + } + + /** + * Воспроизвести встроенный звуковой эффект через Web Audio API. + * Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная. + * Поддерживаемые: jump, pickup, win, lose, click, hit, coin. + */ + _playSound(payload) { + if (!payload || typeof payload.name !== 'string') return; + const name = payload.name; + const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1; + const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1; + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const t = ctx.currentTime; + // Описание звуков: одна или несколько oscillator-волн с envelope + switch (name) { + case 'jump': this._sfxJump(ctx, t, volume, pitch); break; + case 'pickup': this._sfxPickup(ctx, t, volume, pitch); break; + case 'win': this._sfxWin(ctx, t, volume, pitch); break; + case 'lose': this._sfxLose(ctx, t, volume, pitch); break; + case 'click': this._sfxClick(ctx, t, volume, pitch); break; + case 'hit': this._sfxHit(ctx, t, volume, pitch); break; + case 'coin': this._sfxCoin(ctx, t, volume, pitch); break; + default: + this._log('warn', `Неизвестный звук: ${name}`); + } + } catch (e) { + // ignore + } + } + + // === Звуковые пресеты (Web Audio) === + _sfxOsc(ctx, t, type, freq0, freq1, dur, vol) { + const osc = ctx.createOscillator(); + osc.type = type; + osc.frequency.setValueAtTime(freq0, t); + if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur); + const g = ctx.createGain(); + g.gain.setValueAtTime(0, t); + g.gain.linearRampToValueAtTime(vol, t + 0.005); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(g).connect(ctx.destination); + osc.start(t); + osc.stop(t + dur + 0.02); + } + _sfxJump(ctx, t, vol, pitch) { + // Похож на встроенный звук прыжка PlayerController. + this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol); + this._sfxOsc(ctx, t, 'sine', 110 * pitch, 60 * pitch, 0.07, 0.35 * vol); + } + _sfxPickup(ctx, t, vol, pitch) { + // Восходящие два тона — «пик-апнул!» + this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol); + this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol); + } + _sfxCoin(ctx, t, vol, pitch) { + // Классический «динь-динь» + this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol); + this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol); + } + _sfxWin(ctx, t, vol, pitch) { + // Мажорный аккорд C-E-G по очереди + const notes = [523, 659, 784]; + notes.forEach((f, i) => { + this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol); + }); + } + _sfxLose(ctx, t, vol, pitch) { + // Нисходящий «провал» + this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol); + this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol); + } + _sfxClick(ctx, t, vol, pitch) { + // Короткий «тик» + this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol); + } + /** + * Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек. + * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. + */ + _spawnParticles(payload) { + if (!payload || !this.scene3d?._spawnParticleEffect) return; + try { + this.scene3d._spawnParticleEffect(payload); + } catch (e) { + this._log('error', 'spawnParticles failed: ' + (e?.message || e)); + } + } + + _sfxHit(ctx, t, vol, pitch) { + // Глухой «тук»: низкий sine + шумовой burst + this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol); + // Шум через короткий buffer-noise + const bufLen = Math.floor(ctx.sampleRate * 0.06); + const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen); + const src = ctx.createBufferSource(); + src.buffer = buf; + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 1000 * pitch; + const g = ctx.createGain(); + g.gain.value = 0.18 * vol; + src.connect(lp).connect(g).connect(ctx.destination); + src.start(t); + } + + // === Универсальное хранилище сейвов (game.save.*) === + _saveProjectId() { + return this.scene3d?._currentProjectId || this.scene3d?.projectId || null; + } + _saveBaseUrl(namespace) { + const pid = this._saveProjectId(); + const uid = this.scene3d?._currentUserId; + if (!pid || !uid) return null; + const ns = encodeURIComponent(namespace || 'default'); + return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`; + } + _saveReply(scriptId, reqId, result) { + for (const sb of this.sandboxes) { + if (sb.scriptId === scriptId) { + try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {} + return; + } + } + } + _saveGet(scriptId, payload) { + const reqId = payload?.reqId; + const url = this._saveBaseUrl(payload?.namespace); + if (!url) { this._saveReply(scriptId, reqId, null); return; } + // GET savegame теперь тоже требует JWT (бэк ужесточили после + // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же + // headers что _saveSet/_saveMerge. + const headers = {}; + try { + const t = localStorage.getItem('Authorization'); + if (t) headers.Authorization = t; + } catch (e) {} + fetch(url, { headers }).then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.data ?? null)) + .catch(() => this._saveReply(scriptId, reqId, null)); + } + _saveGetAll(scriptId, payload) { + const reqId = payload?.reqId; + const pid = this._saveProjectId(); + const uid = this.scene3d?._currentUserId; + if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; } + const headers = {}; + try { + const t = localStorage.getItem('Authorization'); + if (t) headers.Authorization = t; + } catch (e) {} + fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers }) + .then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) + .catch(() => this._saveReply(scriptId, reqId, {})); + } + // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...) + // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере + // (player.rublox.pro) этих SPA-роутов нет. + // + // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии + // на mnk, разные домены/localStorage; получался 401): + // - http(s)://... → как есть (уже абсолютный) + // - /kubikon/gd* → rublox.pro/app/gd (порт меню GD) + // - /kubikon/play/N → ticket-flow в плеер уже идёт; + // сюда попасть можно только через + // app.navigate из скрипта уровня + // (Например 'играть ещё раз' → + // /kubikon/play/296?play=1&t=ts). + // Парсим id и шлём прямо в плеер. + // - /kubikon*, /kubikon3d* → rublox.pro/app (лента игр) + // - /app, /app/* → rublox.pro + // - всё остальное → rublox.pro/app (фоллбек) + // + // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro. + _resolveExternalUrl(url) { + try { + // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app). + // На dev rublox-site обычно крутится на :3004 — можно переопределить. + const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; + const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; + const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, ''); // .../app → ... + + if (!url) return RUBLOX_HOME; + if (/^https?:\/\//i.test(url)) return url; + + // /kubikon/play/ — рестарт уровня. Перезагружаем плеер сам. + const playMatch = url.match(/^\/kubikon\/play\/(\d+)/); + if (playMatch) { + const playerBase = typeof window !== 'undefined' + ? `${window.location.protocol}//${window.location.host}` + : ''; + return `${playerBase}/${playMatch[1]}`; + } + // Legacy /kubikon/* роуты — редирект на главный сайт. + if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd'; + if (url.startsWith('/kubikon')) return rubloxBase + '/app'; + if (url.startsWith('/app')) return rubloxBase + url; + return rubloxBase + '/app'; + } catch (e) { + const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; + return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; + } + } + // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401). + // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка + // (этот же баг там тоже есть — сохранения GD-прогресса не работали + // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT + // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js + // saveJWT — он кладёт JWT в оба ключа). + _saveAuthHeaders() { + const h = { 'Content-Type': 'application/json' }; + try { + const t = localStorage.getItem('Authorization'); + if (t) h.Authorization = t; + } catch (e) {} + return h; + } + _saveSet(payload) { + const url = this._saveBaseUrl(payload?.namespace); + if (!url) return; + try { + fetch(url, { + method: 'POST', + headers: this._saveAuthHeaders(), + body: JSON.stringify({ data: payload.data }), + }).catch(() => {}); + } catch (e) {} + } + _saveMerge(payload) { + const url = this._saveBaseUrl(payload?.namespace); + if (!url) return; + try { + fetch(url + '/merge', { + method: 'POST', + headers: this._saveAuthHeaders(), + body: JSON.stringify({ + patch: payload.patch || {}, + increment: payload.increment || {}, + max: payload.max || {}, + }), + }).catch(() => {}); + } catch (e) {} + } + _saveLeaderboard(scriptId, payload) { + const reqId = payload?.reqId; + const pid = this._saveProjectId(); + if (!pid) { this._saveReply(scriptId, reqId, []); return; } + const params = new URLSearchParams({ + namespace: payload?.namespace || '', + key: payload?.key || '', + order: payload?.order || 'desc', + limit: '20', + }); + fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`) + .then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.entries || [])) + .catch(() => this._saveReply(scriptId, reqId, [])); + } + + // ============== ECONOMY API (GD-reward через storys) ============== + // Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization. + // Ответ возвращается в Worker через postMessage cmd='economyResponse'. + + _economyReply(scriptId, reqId, result) { + for (const sb of this.sandboxes) { + if (sb.scriptId === scriptId) { + try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {} + return; + } + } + } + + _economyAuthHeaders() { + const h = { 'Content-Type': 'application/json' }; + try { + const t = localStorage.getItem('Authorization'); + if (t) h.Authorization = t; + } catch (e) {} + return h; + } + + _economyReward(scriptId, payload) { + const reqId = payload?.reqId; + const aid = String(payload?.achievementId || ''); + if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; } + fetch(`${STORYS_addres}/kubikon3d/gd/reward`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({ achievement_id: aid }), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) + .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); + } + + _economyDailyCheck(scriptId, payload) { + const reqId = payload?.reqId; + fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { awarded: false })) + .catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) })); + } + + _economyGetBalance(scriptId, payload) { + const reqId = payload?.reqId; + // Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating. + // Делаем оба запроса параллельно. + const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); + const headers = this._economyAuthHeaders(); + Promise.all([ + fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })), + fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })), + ]).then(([dm, rt]) => { + this._economyReply(scriptId, reqId, { + diamonds: Number(dm.count || 0), + rating: Number(rt.rating || 0), + }); + }).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 })); + } + + _economySpend(scriptId, payload) { + const reqId = payload?.reqId; + const amount = Number(payload?.amount || 0); + const reason = String(payload?.reason || 'gd_spend'); + if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; } + const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); + fetch(`${USER_BASE}/api/v1/users/diamond/spend`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({ amount, reason }), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) + .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); + } +} diff --git a/src/engine/LoadingScreenOverlay.js b/src/engine/LoadingScreenOverlay.js new file mode 100644 index 0000000..47f6b56 --- /dev/null +++ b/src/engine/LoadingScreenOverlay.js @@ -0,0 +1,399 @@ +/** + * LoadingScreenOverlay — внутриигровой экран загрузки (задача 12). + * + * Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью + * (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер + * «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу + * (появляется через 0.5с — анти-accidental), логотип игры слева-снизу. + * + * Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts). + * Покрывает и кейс задачи 05 (начальный экран при входе). + * + * Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не + * Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML + * делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM, + * имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi, + * которому tick не нужен). + * + * Один активный экран одновременно: повторный show() мгновенно закрывает + * предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких + * transition подряд. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3); + +// CSS спиннера вставляем один раз в (keyframes нельзя инлайнить в style). +let _spinCssInjected = false; +function injectSpinnerCss() { + if (_spinCssInjected) return; + _spinCssInjected = true; + try { + const style = document.createElement('style'); + style.id = 'kbn-loading-spin-css'; + style.textContent = + '@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' + + '.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' + + '@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}'; + document.head.appendChild(style); + } catch { /* ignore */ } +} + +export class LoadingScreenOverlay { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this._st = null; // state активного экрана или null + this._idSeq = 0; + // Мост наружу (GameRuntime подписывает) — id-based колбэки. + this._onSkipCb = null; // (id) => void + this._onCompleteCb = null; // (id) => void + // DOM-ссылки активного экрана: + this._els = null; + } + + /** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */ + setBridge(onSkip, onComplete) { + this._onSkipCb = onSkip; + this._onCompleteCb = onComplete; + } + + /** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */ + _cfg() { + return (this.s && this.s._loadingConfig) || {}; + } + + /** + * Показать экран загрузки. Возвращает числовой id (для матчинга команд). + * opts — см. 12_ingame_loading.md §2.2. + */ + show(opts) { + injectSpinnerCss(); + opts = opts && typeof opts === 'object' ? opts : {}; + // Один активный — мгновенно убрать предыдущий. + if (this._st) this._instantClose(); + + const cfg = this._cfg(); + const accent = opts.progressColor || cfg.accentColor || '#ffc020'; + const st = { + id: ++this._idSeq, + // Фон + bgColor: opts.bgColor || '#000', + bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1, + fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3, + fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3, + // Прогресс + progressBar: opts.progressBar !== false, + progressColor: accent, + progressBgColor: opts.progressBgColor || '#444', + percentText: opts.percentText !== false, + progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)), + duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null, + manualProgress: false, + // Спиннер + spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false), + spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА', + // Кнопка Пропустить + skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton, + skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ', + skipButtonColor: opts.skipButtonColor || accent, + skipShown: false, + // Логотип + logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null, + logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12, + // Текст под картинкой + text: opts.text != null ? String(opts.text) : '', + // Поведение + blockInput: opts.blockInput !== false, + pauseSimulation: opts.pauseSimulation !== false, + // Жизненный цикл + phase: 'in', // 'in' | 'visible' | 'out' + alpha: 0, + elapsed: 0, // время с момента полного появления (для duration/skip) + fadeT: 0, + completed: false, // onComplete уже вызывался + }; + this._st = st; + this._build(st, opts.cover); + + // Блок ввода + пауза симуляции. + if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } } + if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } } + + return st.id; + } + + /** Резолв cover в URL/dataURL. */ + _resolveCover(cover) { + if (!cover) return null; + if (typeof cover === 'string') { + // asset:xxx → пробуем через AssetManager, иначе как прямой URL. + try { + const r = this.s.assetManager?.resolveUrl?.(cover); + if (r) return r; + } catch { /* ignore */ } + return cover; + } + if (typeof cover === 'object') { + if (cover.sceneSnapshot) { + try { + const canvas = this.s.engine?.getRenderingCanvas?.(); + if (canvas) return canvas.toDataURL('image/jpeg', 0.72); + } catch { /* ignore */ } + return null; + } + if (cover.url) return cover.url; + } + return null; + } + + _build(st, cover) { + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const root = document.createElement('div'); + root.className = 'kbn-loading'; + root.style.cssText = + 'position:absolute;inset:0;z-index:60;overflow:hidden;' + + 'display:flex;align-items:center;justify-content:center;' + + 'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' + + `background:${st.bgColor};`; + // фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным + // (используем opacity всего root для fade, а bgOpacity — через rgba фон): + root.style.background = this._bgRgba(st.bgColor, st.bgOpacity); + + // --- Cover (картинка по центру) --- + const coverUrl = this._resolveCover(cover); + const coverImg = document.createElement('div'); + coverImg.style.cssText = + 'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' + + 'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' + + 'background-color:#1a1f2b;margin-bottom:140px;'; + if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`; + + // --- Текст под картинкой --- + const textEl = document.createElement('div'); + textEl.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' + + 'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);'; + textEl.textContent = st.text || ''; + + // --- Прогресс-бар --- + const barWrap = document.createElement('div'); + barWrap.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' + + `width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` + + 'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' + + (st.progressBar ? '' : 'display:none;'); + const bar = document.createElement('div'); + bar.style.cssText = + `height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` + + `background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` + + 'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);'; + barWrap.appendChild(bar); + + // --- Процент --- + const percent = document.createElement('div'); + percent.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' + + `color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` + + (st.percentText ? '' : 'display:none;'); + percent.textContent = `${Math.round(st.progress * 100)}%`; + + // --- Кнопка Пропустить --- + const skipBtn = document.createElement('button'); + skipBtn.type = 'button'; + skipBtn.textContent = st.skipButtonText; + skipBtn.style.cssText = + 'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' + + 'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' + + `background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` + + 'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' + + 'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' + + 'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' + + (st.skipButton ? '' : 'display:none;'); + skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; }; + skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; }; + skipBtn.onclick = () => { + if (skipBtn.style.pointerEvents === 'none') return; + this._fireSkip(); + }; + + // --- Логотип (слева снизу) --- + const logo = document.createElement('div'); + logo.style.cssText = + 'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' + + `border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` + + 'background-position:left bottom;width:200px;height:90px;'; + if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`; + else logo.style.display = 'none'; + + // --- Спиннер + «ЗАГРУЗКА» (справа снизу) --- + const spinWrap = document.createElement('div'); + spinWrap.style.cssText = + 'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' + + 'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' + + (st.spinner ? '' : 'display:none;'); + const spinTxt = document.createElement('span'); + spinTxt.textContent = st.spinnerText; + const spinCircle = document.createElement('span'); + spinCircle.className = 'kbn-ls-spinner'; + spinCircle.style.cssText = + `display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` + + `border-top-color:${st.progressColor};border-radius:50%;`; + spinWrap.appendChild(spinTxt); + spinWrap.appendChild(spinCircle); + + root.appendChild(coverImg); + root.appendChild(textEl); + root.appendChild(barWrap); + root.appendChild(percent); + root.appendChild(skipBtn); + root.appendChild(logo); + root.appendChild(spinWrap); + parent.appendChild(root); + + this.root = root; + this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap }; + } + + /** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */ + tick(dt) { + const st = this._st; + if (!st || !this._els) return; + dt = Number(dt) || 0; + + if (st.phase === 'in') { + st.fadeT += dt; + const d = st.fadeIn > 0 ? st.fadeIn : 0.0001; + st.alpha = Math.min(1, EASE_OUT(st.fadeT / d)); + this._els.root.style.opacity = String(st.alpha); + if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; } + } else if (st.phase === 'visible') { + st.elapsed += dt; + // Кнопка Пропустить — появляется через 0.5с. + if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) { + st.skipShown = true; + this._els.skipBtn.style.opacity = '1'; + this._els.skipBtn.style.pointerEvents = 'auto'; + } + // Авто-duration (если не было ручного setProgress). + if (st.duration && !st.manualProgress) { + st.progress = Math.min(1, st.elapsed / st.duration); + this._applyProgress(st); + if (st.progress >= 1 && !st.completed) { + st.completed = true; + this._fireComplete(); + this.close(); + } + } + } else if (st.phase === 'out') { + st.fadeT += dt; + const d = st.fadeOut > 0 ? st.fadeOut : 0.0001; + st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d)); + this._els.root.style.opacity = String(st.alpha); + if (st.fadeT >= d) this._teardown(); + } + } + + _applyProgress(st) { + if (!this._els) return; + this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`; + this._els.percent.textContent = `${Math.round(st.progress * 100)}%`; + } + + setProgress(value) { + const st = this._st; + if (!st) return; + st.manualProgress = true; + st.progress = Math.max(0, Math.min(1, Number(value) || 0)); + this._applyProgress(st); + if (st.progress >= 1 && !st.completed) { + st.completed = true; + this._fireComplete(); + this.close(); + } + } + + setText(text) { + const st = this._st; + if (!st || !this._els) return; + st.text = String(text == null ? '' : text); + this._els.textEl.textContent = st.text; + } + + setCover(cover) { + if (!this._st || !this._els) return; + const url = this._resolveCover(cover); + if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`; + } + + /** Закрыть программно (с fadeOut). */ + close() { + const st = this._st; + if (!st) return; + if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; } + } + + _fireSkip() { + const st = this._st; + if (!st) return; + if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } } + this.close(); + } + + _fireComplete() { + const st = this._st; + if (!st) return; + if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } } + } + + /** Мгновенно убрать без fade (повторный show / выход из Play). */ + _instantClose() { + this._teardown(); + } + + _teardown() { + // Снять блок ввода / паузу. + const st = this._st; + if (st) { + if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } } + if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } } + } + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } } + this.root = null; + this._els = null; + this._st = null; + } + + dispose() { + this._instantClose(); + this._onSkipCb = null; + this._onCompleteCb = null; + } + + // --- утилиты цвета --- + _lighten(hex) { + try { + const h = String(hex).replace('#', ''); + if (h.length !== 6) return hex; + const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40); + const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40); + const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40); + return `rgb(${r},${g},${b})`; + } catch { return hex; } + } + + _bgRgba(hex, opacity) { + try { + const h = String(hex).replace('#', ''); + if (h.length !== 6) return hex; + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1; + return `rgba(${r},${g},${b},${a})`; + } catch { return hex; } + } +} diff --git a/src/engine/ModelManager.js b/src/engine/ModelManager.js index 4a6a75f..954bbef 100644 --- a/src/engine/ModelManager.js +++ b/src/engine/ModelManager.js @@ -314,9 +314,10 @@ export class ModelManager { r.getChildMeshes(false).forEach(m => { m.isPickable = true; m.metadata = { isModel: true, instanceId: this._nextInstanceId }; - // Тени: GLB-модель и принимает тени, и отбрасывает их - // (через addShadowCaster в refreshAllShadows). - m.receiveShadows = true; + // Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка). + if (m.getClassName && m.getClassName() !== 'InstancedMesh') { + m.receiveShadows = true; + } clonedMeshes.push(m); }); // И сам root тоже на всякий diff --git a/src/engine/PlacementManager.js b/src/engine/PlacementManager.js new file mode 100644 index 0000000..5a02d8e --- /dev/null +++ b/src/engine/PlacementManager.js @@ -0,0 +1,586 @@ +/** + * PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11). + * + * Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре → + * полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет». + * Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon). + * + * Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`. + * Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx. + * + * Скриптовый API игры (через GameRuntime → game.placement.*): + * start(itemKey, opts) — войти в режим расстановки + * cancel() — выйти (как ПКМ/Esc) + * confirm() — поставить на текущей позиции (как ЛКМ) + * rotate(deg) — повернуть preview (как R / колесо) + * onPlace / onCancel / onMove — колбэки (роутятся в worker как события) + * + * Фича-парность: идентичный модуль есть в rublox-player/src/engine/. + */ +import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core'; + +const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить +const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя + +export class PlacementManager { + constructor(scene3d) { + this.s = scene3d; // BabylonScene + this.scene = scene3d.scene; + this._active = null; // активная сессия placement или null + this._tickObs = null; // observer renderLoop + this._placementSeq = 0; + // Колбэки (вызываются движком, GameRuntime роутит их в worker как события) + this._onPlace = null; + this._onCancel = null; + this._onMove = null; + } + + setCallbacks({ onPlace, onCancel, onMove } = {}) { + if (onPlace !== undefined) this._onPlace = onPlace; + if (onCancel !== undefined) this._onCancel = onCancel; + if (onMove !== undefined) this._onMove = onMove; + } + + isActive() { return !!this._active; } + + /** + * Войти в placement-режим. + * @param {string} itemKey — ключ предмета (передаётся обратно в onPlace) + * @param {object} opts — см. 11_placement_mode.md §2.1 + * @returns {string} placementId + */ + start(itemKey, opts = {}) { + // Уже активна сессия — отменим прежнюю (без onCancel-шума автора). + if (this._active) this._teardown(false); + + const o = { + previewType: opts.previewType || 'primitive:cube', + previewColor: opts.previewColor || '#a0522d', + previewScale: Number(opts.previewScale) || 1, + // modelScale — реальный scale воксельной модели для превью (чтобы + // полупрозрачная копия была того же размера, что и ставимый объект). + modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1, + ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5, + surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag' + allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null, + forbidOverlap: opts.forbidOverlap !== false, + grid: opts.grid != null ? Number(opts.grid) : 1, + rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90, + targetZone: opts.targetZone || null, // ref-строка примитива-зоны + showZoneOutline: opts.showZoneOutline !== false, + showArrowFrom: opts.showArrowFrom || null, // 'player' | ref + cost: Number(opts.cost) || 0, + currency: opts.currency || 'rubles', + hint: opts.hint || '', + hintError: opts.hintError || 'Разместите в отмеченном месте!', + placedType: opts.placedType || null, + chainPlace: !!opts.chainPlace, + maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0, + maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0, + forceCameraMode: opts.forceCameraMode !== false, + freezePlayer: !!opts.freezePlayer, + previewPulse: opts.previewPulse !== false, + }; + + const id = 'placement_' + (++this._placementSeq); + const preview = this._createPreview(o); + + this._active = { + id, itemKey, opts: o, preview, + rotationY: 0, + valid: false, + pos: new Vector3(0, 0, 0), + zoneOutline: null, + arrowFxRef: null, + placedCount: 0, + pulseT: 0, + prevCameraMode: null, + prevFrozen: null, + }; + + // Зона размещения — красный контур по AABB. + if (o.targetZone && o.showZoneOutline) this._createZoneOutline(); + // Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08). + if (o.showArrowFrom && o.targetZone) this._createArrow(); + // Камера: placement требует видимый курсор — в first переводим в third. + if (o.forceCameraMode) this._forceThirdCamera(); + // Заморозка игрока (опция). + if (o.freezePlayer) this._setPlayerFrozen(true); + + // HUD: подсказки снизу-справа + верхний hint. Сообщаем движку. + this._emitHud(true); + + this._startTick(); + return id; + } + + cancel() { + if (!this._active) return; + const cb = this._onCancel; + this._teardown(true); + if (typeof cb === 'function') cb(); + } + + /** Поставить на текущей позиции (как ЛКМ). */ + confirm() { + const a = this._active; + if (!a) return false; + if (!a.valid) { + // Невалидно — звук «не получилось» + мигание preview в красный. + this._playFail(); + this._flashInvalid(); + return false; + } + // Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом + // поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором — + // ровно туда, где показывалось превью. Для куба-превью offset = 0. + let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0; + if (ox || oz) { + const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY); + const rx = ox * c - oz * s; + const rz = ox * s + oz * c; + ox = rx; oz = rz; + } + const result = { + itemKey: a.itemKey, + position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz }, + rotationY: a.rotationY, + }; + // Списание стоимости (если задана и есть валюта-хелпер в движке). + if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost); + a.placedCount++; + this._playPlace(); + + if (typeof this._onPlace === 'function') this._onPlace(result); + + if (a.opts.chainPlace) { + // Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем. + // Просто продолжаем тик; valid пересчитается в следующем кадре. + return true; + } + this._teardown(false); + return true; + } + + /** Повернуть preview на N градусов вокруг Y. */ + rotate(deg) { + const a = this._active; + if (!a) return; + const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90; + a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2); + if (a.preview) a.preview.rotation.y = a.rotationY; + } + + // ── Внутреннее ────────────────────────────────────────────────────── + + _createPreview(o) { + const base = Color3.FromHexString(o.previewColor || '#a0522d'); + + // Для воксельной модели (user:) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ + // модели — полупрозрачную копию. Так тень точно повторяет форму предмета + // И совпадает по позиционированию с реальным spawn (модель растёт от угла + // root, а не центрируется — куб-превью раньше центрировался → предмет + // вставал в угол превью). Здесь превью = тот же addInstance, поэтому + // угол-в-угол. Делается асинхронно (см. _buildUserModelPreview). + const pt = o.previewType || ''; + if (pt.indexOf('user:') === 0 && this.s.userModelManager) { + // Временный куб-заглушка пока модель грузится (1-2 кадра), заменим. + const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene); + stub.isPickable = false; + stub._baseColor = base; + this._buildUserModelPreview(pt, o, base); + return stub; + } + + // Примитивы / прочее — полупрозрачный куб размером previewScale (юниты). + const edge = Number(o.previewScale) || 1; + const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene); + const mat = new StandardMaterial('placementGhostMat', this.scene); + mat.diffuseColor = base; + mat.emissiveColor = base.scale(0.25); + mat.specularColor = new Color3(0, 0, 0); + mat.alpha = o.ghostOpacity; + mat.disableLighting = true; + ghost.material = mat; + ghost.isPickable = false; + ghost._baseColor = base; + return ghost; + } + + /** Построить полупрозрачное превью из реальной воксельной модели (async). */ + async _buildUserModelPreview(previewType, o, base) { + try { + const um = this.s.userModelManager; + // Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью. + const instId = await um.addInstance(previewType, 0, 0, 0, 0, { + scale: o.modelScale || o.previewScale || 1, + canCollide: false, visible: true, anchored: true, + currentUserId: this.s._currentUserId || null, + }); + if (instId == null) return; + // Сессия уже могла завершиться/смениться, пока грузилось. + const a = this._active; + if (!a) { try { um.removeInstance(instId); } catch (e) {} return; } + const inst = um.instances.get(instId); + if (!inst || !inst.rootNode) return; + // Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable. + const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene); + ghostMat.diffuseColor = base; + ghostMat.emissiveColor = base.scale(0.25); + ghostMat.specularColor = new Color3(0, 0, 0); + ghostMat.alpha = o.ghostOpacity; + ghostMat.disableLighting = true; + ghostMat.backFaceCulling = false; + for (const m of (inst.meshes || [])) { + m.isPickable = false; + m.material = ghostMat; + } + // Центр модели по X/Z (воксели растут углом от root → центр смещён). + // Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0). + // Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр + // по X/Z) было ровно под курсором, а не угол. Применяется и к превью, + // и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали. + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const m of (inst.meshes || [])) { + m.computeWorldMatrix(true); + const bb = m.getBoundingInfo().boundingBox; + minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x); + minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z); + } + const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0; + const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0; + a._modelOffsetX = offX; + a._modelOffsetZ = offZ; + + // Удаляем временный stub, новый root становится превью. + const old = a.preview; + a.preview = inst.rootNode; + a.preview._baseColor = base; + a.preview._userModelInstId = instId; // для teardown + a.preview._ghostMat = ghostMat; + if (old) { try { old.dispose(); } catch (e) {} } + } catch (e) { + // тихо — превью некритично, останется stub + } + } + + _startTick() { + this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick()); + } + + _tick() { + const a = this._active; + if (!a) return; + const scn = this.scene; + + // Raycast от камеры через текущую позицию курсора. + const pick = scn.pick(scn.pointerX, scn.pointerY, (m) => + m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts)); + if (pick && pick.hit && pick.pickedPoint) { + let p = pick.pickedPoint.clone(); + // surfaceMode 'ground' — нормаль должна смотреть вверх. + // Поверхность валидна, если смотрит вверх (горизонтальная грань). + // Это и пол, и ВЕРХ другого объекта → можно строить стопкой. + let surfOk = true; + if (a.opts.surfaceMode === 'ground') { + const n = pick.getNormal(true); + surfOk = n && n.y > 0.6; // только грань, обращённая вверх + } + // Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект + // лёг ровно сверху на пол ИЛИ на другой объект (стопка). + if (a.opts.grid > 0) { + p.x = Math.round(p.x / a.opts.grid) * a.opts.grid; + p.z = Math.round(p.z / a.opts.grid) * a.opts.grid; + } + a.pos.copyFrom(p); + if (a.preview) { + if (a.preview._userModelInstId != null) { + // userModel-превью: root = угол модели. Вычитаем offset центра + // по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором. + // Высота p.y без сдвига (низ модели на поверхность). + a.preview.position.set( + p.x - (a._modelOffsetX || 0), + p.y, + p.z - (a._modelOffsetZ || 0), + ); + } else { + // Куб-превью центрирован → поднимаем на полвысоты. + a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z); + } + } + // Валидность. forbidOverlap теперь означает «не врезаться вбок в + // объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена. + a.valid = surfOk + && this._inZone(p, a.opts) + && this._distanceOk(p, a.opts) + && this._limitOk(a.opts) + && this._affordable(a) + && (!a.opts.forbidOverlap || !this._overlapsSide(p, a)); + } else { + a.valid = false; + } + + // Цвет preview: зелёный/красный. + this._applyTint(a, a.valid); + + // Пульсация прозрачности (привлекает внимание). Материал — у куба-превью + // напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat). + const pmat = a.preview && (a.preview.material || a.preview._ghostMat); + if (a.opts.previewPulse && pmat) { + a.pulseT += this.scene.getEngine().getDeltaTime() / 1000; + const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1 + pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k); + } + + // HUD-индикатор ошибки (красный текст когда невалидно). + this._emitHudError(!a.valid); + + // Стрелка к зоне — обновим конечную точку (если игрок движется). + if (a.arrowFxRef) this._updateArrow(); + + // onMove колбэк автору (каждый кадр). + if (typeof this._onMove === 'function') { + this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid }); + } + } + + _applyTint(a, valid) { + // Материал куба-превью напрямую, userModel-превью — в _ghostMat. + const pmat = a.preview && (a.preview.material || a.preview._ghostMat); + if (!pmat) return; + if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) { + return; // во время flash держим красный + } + const tint = valid ? VALID_TINT : INVALID_TINT; + // Смешиваем базовый цвет с tint-ом (multiply-эффект). + const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25); + pmat.diffuseColor = new Color3( + b.r * tint.r + tint.r * 0.4, + b.g * tint.g + tint.g * 0.4, + b.b * tint.b + tint.b * 0.4, + ); + pmat.emissiveColor = tint.scale(0.35); + } + + _flashInvalid() { + const a = this._active; + if (!a || !a.preview || !a.preview.material) return; + try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; } + a.preview.material.diffuseColor = INVALID_TINT; + a.preview.material.emissiveColor = INVALID_TINT.scale(0.6); + } + + _isSurface(mesh, o) { + if (!o.allowSurfaces) return true; // любая поверхность + // Совпадение по имени или тегу. + const name = mesh.name || ''; + if (o.allowSurfaces.some(s => name.includes(s))) return true; + const tags = mesh.metadata && mesh.metadata.tags; + if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true; + return false; + } + + _inZone(p, o) { + if (!o.targetZone) return true; + const z = this._resolveZoneMesh(o.targetZone); + if (!z) return true; + const bb = z.getBoundingInfo().boundingBox; + const min = bb.minimumWorld, max = bb.maximumWorld; + return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z; + } + + _distanceOk(p, o) { + if (!o.maxDistance || o.maxDistance <= 0) return true; + const pl = this.s.player && this.s.player._pos; + if (!pl) return true; + const dx = p.x - pl.x, dz = p.z - pl.z; + return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance; + } + + _limitOk(o) { + if (!o.maxItems || o.maxItems <= 0) return true; + return (this._active.placedCount || 0) < o.maxItems; + } + + _overlapsSide(p, a) { + // Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте + // (его тело пересекает уровень, куда ляжет новый объект). Объект строго + // НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет + // строить башню из кубов, но не даёт двум кубам слипнуться вбок. + const r = Math.max(0.45, (a.opts.grid || 1) * 0.5); + const newY = p.y; // высота поверхности (низ нового объекта) + const newTop = newY + (a.opts.previewScale || 1); + for (const m of this.scene.meshes) { + if (!m.isPickable || m === a.preview) continue; + if (!m.getBoundingInfo) continue; + const bb = m.getBoundingInfo().boundingBox; + const sizeX = bb.maximumWorld.x - bb.minimumWorld.x; + if (sizeX > 8) continue; // пол/большая поверхность — не препятствие + const c = bb.centerWorld; + const dx = c.x - p.x, dz = c.z - p.z; + if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором + const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y; + // Пересечение по вертикали: тела перекрываются по Y → бок в бок. + const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05); + if (overlapY) return true; + } + return false; + } + + /** Хватает ли валюты на текущий предмет (если задан баланс). */ + _affordable(a) { + const cur = a.opts.currency; + const cost = a.opts.cost || 0; + if (!cost) return true; + const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity; + return cost <= bal; + } + + /** Установить баланс валюты (для проверки «нельзя уйти в минус»). */ + setBalance(currency, amount) { + if (!this._balances) this._balances = {}; + if (currency) this._balances[currency] = Number(amount) || 0; + } + + _resolveZoneMesh(ref) { + // ref может быть строкой ('primitive:N' / имя) или уже мешем. + if (ref && ref.getBoundingInfo) return ref; + if (typeof ref === 'string') { + // через scene3d — найти примитив/модель по ref + try { + const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null; + if (mesh) return mesh; + } catch { /* ignore */ } + // fallback — по имени + return this.scene.getMeshByName(ref) || null; + } + return null; + } + + _createZoneOutline() { + const a = this._active; + const z = this._resolveZoneMesh(a.opts.targetZone); + if (!z) return; + const bb = z.getBoundingInfo().boundingBox; + const min = bb.minimumWorld, max = bb.maximumWorld; + const y = min.y + 0.06; + const pts = [ + new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z), + new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z), + new Vector3(min.x, y, min.z), + ]; + const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene); + line.color = new Color3(1, 0.19, 0.19); + line.isPickable = false; + // glow-имитация: чуть приподнятая полупрозрачная плоскость + a.zoneOutline = line; + } + + _createArrow() { + const a = this._active; + // Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget). + try { + const bm = this.s.beamManager; + if (!bm || !bm.addPointer) return; + const z = this._resolveZoneMesh(a.opts.targetZone); + if (!z) return; + const c = z.getBoundingInfo().boundingBox.centerWorld; + const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos) + ? this.s.player._pos + : this._resolveZoneMesh(a.opts.showArrowFrom); + const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null; + if (!fromV) return; + a.arrowFxRef = bm.addPointer({ + from: { x: fromV.x, y: fromV.y, z: fromV.z }, + to: { x: c.x, y: c.y + 0.6, z: c.z }, + preset: 'guide', + }); + } catch { /* стрелка не критична */ } + } + + _updateArrow() { + // Стрелка статична от точки старта к зоне (как в Roblox tycoon — + // указатель «куда ставить»). BeamManager не имеет setPointerOrigin, + // а пересоздавать каждый кадр дорого. Конец уже привязан к зоне. + } + + _forceThirdCamera() { + const a = this._active; + try { + if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) { + a.prevCameraMode = this.s.player.getCameraMode(); + if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third'); + } + } catch { /* ignore */ } + } + + _setPlayerFrozen(frozen) { + try { + if (this.s.player && this.s.player.setFrozen) { + if (this._active) this._active.prevFrozen = true; + this.s.player.setFrozen(frozen); + } + } catch { /* ignore */ } + } + + _spendCurrency(currency, amount) { + // Движок не держит «кошелёк» — это делает игра через onPlace + save. + // Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет). + try { + if (this.s.spendCurrency) this.s.spendCurrency(currency, amount); + } catch { /* ignore */ } + } + + _playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } } + _playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } } + + _emitHud(show) { + // Сообщаем движку показать/скрыть placement-HUD (подсказки). + try { + if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' }); + } catch { /* ignore */ } + } + + _emitHudError(isError) { + try { + if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError); + } catch { /* ignore */ } + } + + _teardown(emitHudOff) { + const a = this._active; + if (!a) return; + if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; } + if (a.preview) { + try { + if (a.preview._userModelInstId != null && this.s.userModelManager) { + // userModel-превью — это реальный инстанс; удаляем через менеджер + // (снимет из Map + dispose мешей). + чистим ghost-материал. + try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {} + this.s.userModelManager.removeInstance(a.preview._userModelInstId); + } else { + a.preview.material && a.preview.material.dispose(); + a.preview.dispose(); + } + } catch { /* ignore */ } + } + if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } } + if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) { + try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ } + } + if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) { + try { this.s.player.setCameraMode('first'); } catch { /* ignore */ } + } + if (a.prevFrozen && this.s.player && this.s.player.setFrozen) { + try { this.s.player.setFrozen(false); } catch { /* ignore */ } + } + this._active = null; + if (emitHudOff !== false) this._emitHud(false); + } + + /** Полный сброс при Stop игры. */ + dispose() { + this._teardown(true); + this._onPlace = this._onCancel = this._onMove = null; + } +} diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index 8bcdc93..0c1ef03 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -1,3097 +1,3249 @@ -/** - * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения). - * - * Камера: - * 1st person — камера в позиции глаз игрока, модель невидима. - * 3rd person — камера сзади-сверху, модель видна. - * Переключение: клавиша C циклит first ↔ third. - * Колесо мыши в third-person — меняет дистанцию (zoom). - * - * Модель игрока: - * Грузим GLB через ModelManager (тот же что для editor-моделей). - * Корневой TransformNode хранит position/rotation модели. - * В 3rd person модель видна и крутится в направлении движения. - * Анимации (idle/walk/sprint) переключаются по скорости/спринту. - * - * Управление: - * W/A/S/D / стрелки — движение в горизонтальной плоскости - * Space — прыжок - * Shift — спринт (×1.7) - * C — переключить камеру 1st ↔ 3rd - * Esc — выйти из игры (через pointer-lock release) - */ -import { - Vector3, UniversalCamera, SceneLoader, TransformNode, - MeshBuilder, StandardMaterial, Color3, - Quaternion, Space, Ray, -} from '@babylonjs/core'; -import { getModelType } from './ModelTypes'; -import { R15Skeleton } from './R15Skeleton'; -import { R15Animator } from './R15Animator'; -// Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md -import { AccessoryManager } from './AccessoryManager'; - -// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). -// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. -const CAMERA_MODES = ['third', 'first', 'front']; -// Для режима 'sideview' (Кубикон Dash): -// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z) -// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб -// и ~12м препятствий впереди влезали в кадр на 16:9. -const SIDEVIEW_DIST = 14; -const SIDEVIEW_HEIGHT = 2.5; - -// Строит абсолютный URL для /api-storys. -// Использует VITE_API_BASE если задан (предпочтительно когда плеер и API -// на разных доменах), иначе fallback: -// - пустой base в dev (vite-proxy роутит сам) -// - текущий origin в prod (предполагаем что API на том же домене) -function _storysApiUrl(path) { - const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - if (env.VITE_API_BASE) { - return env.VITE_API_BASE + '/api-storys' + path; - } - const isDev = (typeof window !== 'undefined' - && (window.location.hostname === 'localhost' - || window.location.hostname === '127.0.0.1')); - const base = isDev ? '' : (typeof window !== 'undefined' ? window.location.origin : ''); - return base + '/api-storys' + path; -} - -export class PlayerController { - constructor(scene, canvas, physics, scene3d = null) { - this.scene = scene; - this.canvas = canvas; - this.physics = physics; - this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint) - this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали - - // AABB - this.HALF_W = 0.3; - this.HALF_H = 0.9; - this.HALF_D = 0.3; - this.EYE_HEIGHT = 0.7; // глаза от центра AABB - - this.WALK_SPEED = 4.5; - this.SPRINT_MULT = 1.7; - this.JUMP_VELOCITY = 8; - this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне) - this._speedMul = 1; // множитель скорости передвижения - this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная) - this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт) - this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе - this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) - this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) - this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) - // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. - // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. - this._autoRunSpeed = 0; - // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе). - // В sideview-камере при прыжке куб эффектно крутится — визитка GD. - this._dashSpinAngle = 0; - // Camera shake: amplitude + remaining time. Применяется в _tick после - // _computeCameraPos. Используется через game.camera.shake(amp, dur). - this._cameraShakeAmp = 0; - this._cameraShakeLeft = 0; - // Управление камерой из скрипта (Фаза 5.7). null = обычная камера - // игрока. Иначе объект режима: - // { mode:'focus', getTarget } — следить за объектом; - // { mode:'cutscene', points, durations, ... } — пролёт по точкам. - this._cameraOverride = null; - // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть. - // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround. - this._coyoteLeft = 0; - this._doubleJumpEnabled = false; - this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте» - // Кубикон Dash: направление гравитации. +1 = нормально (вниз), - // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD). - // Применяется только в sideview-режиме. Влияет на: - // - vy += GRAVITY * gravityDir * dt - // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз) - // - "onGround" определение: hitY + vy*gravityDir < 0 - // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте). - this._gravityDir = 1; - // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка, - // 1 = полностью скользко (инерция держится бесконечно). Реалистичный - // лёд = ~0.85. Настраивается через game.player.setIceFriction(value). - this._iceFriction = 0; - this._iceVelX = 0; - this._iceVelZ = 0; - // Присед — уменьшает высоту AABB. Включается через - // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45. - this._crouching = false; - this.HALF_H_NORMAL = 0.9; - this.HALF_H_CROUCH = 0.45; - this.GRAVITY = -22; - this.MOUSE_SENSITIVITY = 0.0025; - - // 3rd person camera - this.THIRD_DISTANCE_MIN = 2.5; - this.THIRD_DISTANCE_MAX = 12; - this.THIRD_DISTANCE_DEFAULT = 5; - this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока - - this.camera = null; - this._active = false; - this._onExitRequest = null; - - // Состояние игрока - this._pos = new Vector3(0, 5, 0); - this._vy = 0; - this._yaw = 0; - this._pitch = 0; - - // Камера. Дефолт — первое лицо (как в большинстве игр). - this._cameraMode = 'third'; - this._thirdDistance = this.THIRD_DISTANCE_DEFAULT; - // Порог авто-перехода third→first при зуме колесом (Roblox-style). - this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; - // Если true — нельзя выйти из first-person зумом (lockfirst-режим). - this._lockFirstPerson = false; - // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере. - this._shiftLock = false; - // Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора. - this._rmbHeld = false; - this._mouseIconVisible = true; - - // Ввод - this._codes = new Set(); - this._shift = false; - - // Auto-step visual smoothing. Когда PhysicsAABB телепортирует - // игрока вверх на уступ (steppedUpBy), физически он уже наверху, - // но мы интерполируем рендер: показываем модель и камеру со сдвигом - // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0. - // Получается визуально как плавный «полупрыжок» без рывка. - this._stepUpVisualOffset = 0; - // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс. - this._stepUpDecay = 4.5; - - // Модель игрока (грузится в start) - // Дефолт — R15-скин bacon-hair (классический Roblox-вид). - this._modelTypeId = 'skin_bacon-hair'; - this._modelRoot = null; - this._modelMeshes = []; - // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет - // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР - // в _tick (после анимаций), чтобы скрин оставался скрытым даже после - // асинхронной загрузки модели или после _applyCameraMode. - this._skinVisibleScripted = true; - this._animations = {}; - this._currentAnim = null; - // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока). - // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72. - this._modelScale = 0.72; - - // === R15-скин (bacon-hair и др.) === - // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций). - // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из - // characters//body.glb, детектируем скелет, анимируем - // процедурно через R15Animator (см. _loadPlayerModel / _tick). - this._isR15 = false; // флаг: загружен валидный R15-скелет - this._r15Skeleton = null; // R15Skeleton — резолвер костей - this._r15Animator = null; // R15Animator — процедурные анимации - this._skinManifest = null; // кеш skins_manifest.json - this._skinOverrides = {}; // overrides текущего скина - - // === non-humanoid скины (задача 07) === - // Скин без R15-скелета (животное, машина, абстрактная модель). - // Для них центрируем pivot, считаем собственный AABB и анимируем - // процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick). - this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh' - this._modelHipHeight = null; // локальная база модели (опущена на ноги) - this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели - this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций) - this._isGrounded = true; // флаг «на земле» (для анимаций) - - // === Блокировка ввода/камеры для модалов (задача 04) === - this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter) - this._cameraFrozen = false; // замораживает вращение/зум камеры - this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget) - - // === Жизни игрока === - this.maxHp = 100; - this.hp = 100; - this._lastDamageTime = 0; - this._invulnerabilityTime = 0.5; // 500мс i-frames после удара - this._onHpChange = null; - this._onDeath = null; - - // Звук шагов — простой генератор через Web Audio API. - // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE. - this._audioCtx = null; - this._distanceSinceLastStep = 0; - this.STEP_DISTANCE_WALK = 1.6; - this.STEP_DISTANCE_SPRINT = 1.1; - - // Угол поворота модели — следует за направлением движения, а не yaw камеры. - // Когда игрок стоит — сохраняем последний угол. - this._modelYaw = 0; - this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с) - - this._listeners = []; - this._beforeRender = null; - - // === Stick к движущейся платформе === - // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его - // на дельту движения этого объекта (его позиция могла измениться). - this._lastGroundData = null; - this._lastGroundPos = null; - - // === Тач-режим (мобилки/планшеты) === - // Если true — pointer-lock не запрашивается, mouse-listener не активен, - // ввод управляется снаружи через setVirtualKey/addCameraDelta. - this._touchMode = false; - // Фактическая скорость поворота камеры от тача (рад/пиксель). - this.TOUCH_SENSITIVITY = 0.005; - } - - /** - * Включить тач-режим. Вызывать ДО start(), на тач-устройствах. - * В этом режиме: - * - pointer-lock НЕ запрашивается - * - mousemove игнорируется - * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры), - * но дополнительно работают setVirtualKey() / addCameraDelta(). - */ - setTouchMode(enabled) { - this._touchMode = !!enabled; - } - - /** - * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code: - * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space' - * Для шифта — отдельный параметр. - */ - setVirtualKey(code, pressed) { - if (pressed) this._codes.add(code); - else this._codes.delete(code); - } - - /** Программное нажатие/отпускание Shift (бег). */ - setVirtualShift(pressed) { - this._shift = !!pressed; - } - - /** - * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены). - * dx, dy — пиксели свайпа. - */ - addCameraDelta(dx, dy) { - this._yaw += dx * this.TOUCH_SENSITIVITY; - this._pitch += dy * this.TOUCH_SENSITIVITY; - const lim = Math.PI / 2 - 0.05; - if (this._pitch > lim) this._pitch = lim; - if (this._pitch < -lim) this._pitch = -lim; - } - - /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */ - triggerJump() { - this._codes.add('Space'); - // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить - // нажатие и инициировать прыжок (он проверяет onGround + Space). - setTimeout(() => this._codes.delete('Space'), 100); - } - - /** - * Аналоговый ввод движения для тач-джойстика. - * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры), - * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint). - * - * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться - * к дискретным клавишам, передай null. - */ - setAnalogMove(x, y) { - if (x === null || y === null) { - this._analogMove = null; - return; - } - if (!this._analogMove) this._analogMove = { x: 0, y: 0 }; - this._analogMove.x = x; - this._analogMove.y = y; - } - - setOnExitRequest(cb) { - this._onExitRequest = cb; - } - - /** Установить тип модели персонажа — должен быть вызван ДО start(). */ - setModelType(typeId) { - this._modelTypeId = typeId || 'character-a'; - } - - /** - * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07). - * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту, - * грузит новую модель (R15 или non-humanoid). Возвращает Promise. - * - * Используется из game.player.setSkin(slug). - */ - async reloadSkin(typeId) { - if (!this._active) return false; - const newType = typeId || 'character-a'; - if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин - // 1) Выгрузить текущую модель и связанные аниматоры. - try { - if (this._modelRoot) { this._modelRoot.dispose(false, true); } - } catch (e) { /* ignore */ } - this._modelRoot = null; - this._modelMeshes = []; - this._rightArmMeshes = []; - this._r15Skeleton = null; - this._r15Animator = null; - this._isR15 = false; - this._modelKind = 'r15'; - this._modelHipHeight = null; - this._nonHumanoidBox = null; - // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит). - this.HALF_W = 0.3; - this.HALF_H = 0.9; - this.HALF_D = 0.3; - this.HALF_H_NORMAL = 0.9; - this.EYE_HEIGHT = 0.7; - // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу. - this._pos.y += 0.5; - // 4) Загрузить новую модель. - this._modelTypeId = newType; - await this._loadPlayerModel(); - return !!this._modelRoot; - } - - /** - * Запустить режим игры. - * spawnPos — точка спавна. Если не указано — (0, 5, 0). - */ - async start(spawnPos = null) { - if (this._active) return; - this._active = true; - - if (spawnPos) { - this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z); - } else { - this._pos = new Vector3(0, 5 + this.HALF_H, 0); - } - this._vy = 0; - this._yaw = 0; - this._pitch = 0; - this._modelYaw = 0; - this._codes.clear(); - this._shift = false; - - // FPS-камера - const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene); - cam.minZ = 0.1; - cam.maxZ = 1000; - cam.fov = 1.05; - cam.inputs.clear(); - this.scene.activeCamera = cam; - this.camera = cam; - - this._setupInput(); - - // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит - // без меша (или появляется частично), а движение/колайдер уже - // активны. start() теперь async-функция — все её вызовы (`await`). - await this._loadPlayerModel(); - - // Render-loop hook - this._beforeRender = () => this._tick(); - this.scene.registerBeforeRender(this._beforeRender); - - // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock - // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно — - // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ. - 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 */ } - } - - /** - * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых - * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём - * pointerlockchange и пробуем снова один раз. - */ - /** - * Включить/выключить «UI-режим курсора». - * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается. - * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false). - */ - /** Колбэк изменения HP — ({hp, maxHp}). */ - setOnHpChange(cb) { this._onHpChange = cb; } - setOnDeath(cb) { this._onDeath = cb; } - - /** Нанести урон игроку (с учётом i-frames). */ - takeDamage(amount, source) { - if (this.hp <= 0) return; - const now = performance.now() / 1000; - if (now - this._lastDamageTime < this._invulnerabilityTime) return; - this._lastDamageTime = now; - this.hp = Math.max(0, this.hp - Math.max(0, amount)); - // Flash-эффект для UI (через onHpChange флаг damaged=true) - if (this._onHpChange) { - try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {} - } - // Звук «ой» - this._playHurtSound(); - if (this.hp === 0) { - // Эффект распада - this._spawnDeathDebris(); - // Прячем модель игрока - if (this._modelRoot) this._modelRoot.setEnabled(false); - if (this._onDeath) { - try { this._onDeath(); } catch (e) {} - } - } - } - - /** Распад на куски при смерти. */ - _spawnDeathDebris() { - if (!this._pos) return; - const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z; - const colors = [ - new Color3(0.95, 0.78, 0.6), // кожа - new Color3(0.7, 0.5, 0.4), - new Color3(0.4, 0.4, 0.7), // одежда - new Color3(0.3, 0.25, 0.2), - ]; - for (let i = 0; i < 10; i++) { - const size = 0.18 + Math.random() * 0.14; - const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene); - const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene); - mat.diffuseColor = colors[i % colors.length]; - mat.specularColor = new Color3(0, 0, 0); - cube.material = mat; - cube.position.set( - cx + (Math.random() - 0.5) * 0.5, - cy + Math.random() * 0.6, - cz + (Math.random() - 0.5) * 0.5 - ); - cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); - cube.isPickable = false; - cube.alwaysSelectAsActiveMesh = true; - const debris = { - mesh: cube, mat, - vx: (Math.random() - 0.5) * 5, - vy: 4 + Math.random() * 3, - vz: (Math.random() - 0.5) * 5, - rx: (Math.random() - 0.5) * 10, - ry: (Math.random() - 0.5) * 10, - rz: (Math.random() - 0.5) * 10, - age: 0, - life: 2.0, - }; - if (!this._debris) this._debris = []; - this._debris.push(debris); - } - } - - /** Тик debris — вызывается в _tick. */ - _tickDebris(dt) { - if (!this._debris || this._debris.length === 0) return; - const G = -10; - const next = []; - for (const d of this._debris) { - d.age += dt; - if (d.age >= d.life) { - try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} - continue; - } - d.vy += G * dt; - d.mesh.position.x += d.vx * dt; - d.mesh.position.y += d.vy * dt; - d.mesh.position.z += d.vz * dt; - if (d.mesh.position.y < 0.1) { - d.mesh.position.y = 0.1; - d.vy *= -0.4; - d.vx *= 0.6; - d.vz *= 0.6; - } - d.mesh.rotation.x += d.rx * dt; - d.mesh.rotation.y += d.ry * dt; - d.mesh.rotation.z += d.rz * dt; - const fadeStart = d.life - 0.5; - if (d.age > fadeStart) { - const k = 1 - (d.age - fadeStart) / 0.5; - d.mesh.visibility = Math.max(0, k); - } - next.push(d); - } - this._debris = next; - } - - /** Короткий звук «ой» когда получили урон. */ - _playHurtSound() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const t = ctx.currentTime; - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(220, t); - osc.frequency.exponentialRampToValueAtTime(80, t + 0.15); - const g = ctx.createGain(); - g.gain.setValueAtTime(0.15, t); - g.gain.exponentialRampToValueAtTime(0.001, t + 0.2); - osc.connect(g).connect(ctx.destination); - osc.start(t); - osc.stop(t + 0.22); - } catch (e) { /* ignore */ } - } - - /** Полное восстановление HP (например при респавне). */ - healFull() { - this.hp = this.maxHp; - this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна - // Возвращаем модель - if (this._modelRoot) this._modelRoot.setEnabled(true); - // Сбрасываем оставшиеся debris - if (this._debris) { - for (const d of this._debris) { - try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} - } - this._debris = []; - } - if (this._onHpChange) { - try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {} - } - } - - setUiCursorMode(enabled) { - this._uiCursorMode = !!enabled; - if (enabled) { - // Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если - // меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit- - // камера после закрытия меню «думает», что ПКМ всё ещё активна. - this._rmbHeld = false; - // Освобождаем мышь - if (document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) { /* ignore */ } - } - } else { - // Возвращаем lock — но только если мы реально активны - if (this._active) { - this._requestPointerLockSafe(); - } - } - } - isUiCursorMode() { return !!this._uiCursorMode; } - - /** - * Callback, который вызывается при движении мыши в UI-режиме. - * fn(x, y) — нормализованные координаты [0..1] относительно канваса. - * Используется для drag-механик (Дальгона и т.д.). - */ - setUiMouseMoveCallback(fn) { - this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null; - } - /** mousedown в UI-режиме. fn(x, y). */ - setUiMouseDownCallback(fn) { - this._uiMouseDownCb = (typeof fn === 'function') ? fn : null; - } - /** mouseup в UI-режиме. fn(x, y). */ - setUiMouseUpCallback(fn) { - this._uiMouseUpCb = (typeof fn === 'function') ? fn : null; - } - - _requestPointerLockSafe(retried = false) { - if (!this._active || !this.canvas?.requestPointerLock) return; - // На тач-устройствах pointer-lock не нужен — управление через touch-overlay - if (this._touchMode) return; - // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui')) - // — не захватываем мышь. - if (this._uiCursorMode) return; - // Если уже есть lock на этот canvas — нечего делать - if (document.pointerLockElement === this.canvas) return; - // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем - if (document.pointerLockElement && document.pointerLockElement !== this.canvas) { - if (retried) return; // только одна попытка повтора - const onChange = () => { - document.removeEventListener('pointerlockchange', onChange); - if (this._active) this._requestPointerLockSafe(true); - }; - document.addEventListener('pointerlockchange', onChange, { once: true }); - return; - } - requestAnimationFrame(() => { - if (!this._active) return; - try { - const p = this.canvas.requestPointerLock(); - // Promise-форма: ловим reject (SecurityError) и пробуем повтор - if (p && typeof p.catch === 'function') { - p.catch((err) => { - if (!this._active) return; - // SecurityError — попробуем ещё раз через кадр (один раз) - if (!retried && err && err.name === 'SecurityError') { - setTimeout(() => this._requestPointerLockSafe(true), 50); - } - }); - } - } catch (e) { /* legacy form, ignore */ } - }); - } - - /** - * Загрузить манифест R15-скинов (характеристики + overrides). - * Кешируется в this._skinManifest. Возвращает массив skins или []. - */ - async _loadSkinManifest() { - if (this._skinManifest) return this._skinManifest; - // ВАЖНО: объединяем ОБА источника, а не «или-или». - // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался - // ТОЛЬКО он, а статичный 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 { - const resp = await fetch(_storysApiUrl('/rublox/avatars')); - if (resp.ok) { - const json = await resp.json(); - const items = json.items || []; - // Нормализуем: file уже полный путь (absolute_file=true), т.к. - // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс. - const avatars = items.map((a) => ({ - id: a.code, - name: a.name, - file: a.file_path, - overrides: a.overrides || {}, - absolute_file: true, - })); - // Аватары имеют приоритет при совпадении id — кладём в начало. - const avatarIds = new Set(avatars.map((a) => a.id)); - combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id))); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] /rublox/avatars failed:', e); - } - this._skinManifest = combined; - return combined; - } - - /** - * Определить путь к GLB и overrides для текущего _modelTypeId. - * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста - * - иначе → старая Kenney-модель через getModelType() - * Возвращает { file, isR15, overrides } или null. - */ - async _resolveModelSource() { - const typeId = this._modelTypeId || 'character-a'; - // eslint-disable-next-line no-console - console.log(`[PlayerController] _resolveModelSource typeId=${typeId}`); - // Подфаза 3.6 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: - // body-skin от дизайнера передаётся как 'body:' или - // прямой URL ('/api-storys/...' или 'http(s)://...'). - // Подтягиваем item из rublox_items.serialize, берём path. - if (typeId.startsWith('body:')) { - const itemId = typeId.slice(5); - const item = await this._fetchBodySkinItem(itemId); - if (item && item.path) { - return { - file: item.path, - isR15: true, - overrides: {}, // overrides пока не поддерживаем для дизайнерских body - }; - } - // fallback на дефолт - return { - file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', - isR15: true, overrides: {}, - }; - } - // 2026-05-27: 'designer_avatar:' — preview-режим для аватара из БД - // (rublox_avatars). Используется в /_preview-avatar/. - if (typeId.startsWith('designer_avatar:')) { - const avId = typeId.slice('designer_avatar:'.length); - const item = await this._fetchDesignerAvatar(avId); - if (item && item.file_path) { - return { - file: item.file_path, - isR15: true, - overrides: item.overrides || {}, - }; - } - // Fallback на бекона — но с глобальным флагом ошибки, чтобы - // preview-route смог показать понятный alert почему. - try { - window.__previewFallbackReason = - this._lastDesignerAvatarError - || `Designer avatar #${avId} не загрузился — fallback на бекона`; - } catch (e) {} - // eslint-disable-next-line no-console - console.warn( - `[PlayerController] designer_avatar:${avId} НЕ загружен. ` - + `Fallback на бекона. Причина: ${this._lastDesignerAvatarError || 'unknown'}` - ); - return { - file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', - isR15: true, overrides: {}, - }; - } - if (typeId.startsWith('/') || typeId.startsWith('http')) { - // Прямой URL (для preview-режима или тестов). - return { file: typeId, isR15: true, overrides: {} }; - } - // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные - // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs. - if (typeId.startsWith('customskin:')) { - const slug = typeId.slice('customskin:'.length); - const list = this._scene3d?._skinsConfig?.customGlbs || []; - const meta = list.find(g => g && g.slug === slug) || null; - const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null; - if (url) { - return { - file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {}, - scaleManifest: meta?.scale ?? 1.5, - hipHeight: meta?.hipHeight ?? 0.4, - rotationYOffset: meta?.rotationYOffset ?? 0, - isDataUrl: true, - }; - } - return null; - } - if (typeId.startsWith('skin_')) { - const manifest = await this._loadSkinManifest(); - const entry = manifest.find((s) => s.id === typeId); - if (entry) { - // kind определяет систему анимации: - // 'r15' → R15-скелет (как раньше) - // 'non-humanoid-mesh' → single-mesh, процедурное покачивание - // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup - // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом). - const kind = entry.kind || 'r15'; - // absolute_file=true (источник /rublox/avatars) — file уже - // полный URL (legacy /kubikon-assets/... или дизайнерский - // /api-storys/...). Без флага — это легаси-формат - // skins_manifest.json без префикса. - const file = entry.absolute_file - ? entry.file - : '/kubikon-assets/' + entry.file; - return { - file, - isR15: kind === 'r15', - kind, - overrides: entry.overrides || {}, - scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null, - hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null, - rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, - }; - } - // нет в манифесте — пробуем прямой путь - return { - file: `/kubikon-assets/characters/${typeId}/body.glb`, - isR15: true, - kind: 'r15', - overrides: {}, - }; - } - const modelType = getModelType(typeId); - if (!modelType) return null; - return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; - } - - /** Подгрузить metadata designer-аватара по id через api-storys. */ - async _fetchDesignerAvatar(avatarId) { - try { - this._designerAvatarCache = this._designerAvatarCache || {}; - if (this._designerAvatarCache[avatarId]) { - return this._designerAvatarCache[avatarId]; - } - const jwt = (localStorage.getItem('player_jwt') - || localStorage.getItem('Authorization') || ''); - const cleanJwt = jwt.startsWith('Bearer ') ? jwt.slice(7) : jwt; - const url = _storysApiUrl(`/designer/avatars/${avatarId}`); - // eslint-disable-next-line no-console - console.log(`[PlayerController] _fetchDesignerAvatar GET ${url}`); - // 10-секундный таймаут — fetch без него может висеть бесконечно - // (CORS-preflight, медленная сеть, упавший прокси). - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 10000); - let resp; - try { - resp = await fetch(url, { - headers: cleanJwt - ? { Authorization: 'Bearer ' + cleanJwt } - : {}, - signal: ctrl.signal, - }); - } finally { clearTimeout(timer); } - if (!resp.ok) { - // Получим тело для диагностики - let detail = ''; - try { - const txt = await resp.text(); - detail = txt.slice(0, 200); - } catch (e) {} - // eslint-disable-next-line no-console - console.warn( - `[PlayerController] _fetchDesignerAvatar id=${avatarId} ` - + `HTTP ${resp.status} ${resp.statusText}; jwt=${cleanJwt ? 'есть' : 'НЕТ'}; ` - + `body: ${detail}`, - ); - // Прокидываем ошибку наверх чтобы preview-route показал alert. - const err = new Error( - `Не удалось получить аватар #${avatarId}: HTTP ${resp.status}. ` - + (resp.status === 401 ? 'JWT не валиден или истёк.' - : resp.status === 403 ? 'Нет роли «дизайнер» в team.' - : resp.status === 404 ? 'Аватар не найден в БД.' - : 'См. консоль для деталей.') - ); - err.status = resp.status; - err.detail = detail; - throw err; - } - const data = await resp.json(); - const item = data.item || data; - this._designerAvatarCache[avatarId] = item; - return item; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] _fetchDesignerAvatar failed:', e); - // Сохраняем ошибку чтобы preview-route мог её показать. - this._lastDesignerAvatarError = e?.message || String(e); - return null; - } - } - - /** Подгрузить metadata body-skin (item) по id через api-storys. */ - async _fetchBodySkinItem(itemId) { - try { - // Кэш на инстанс — _modelTypeId не меняется внутри одной сессии. - this._bodySkinCache = this._bodySkinCache || {}; - if (this._bodySkinCache[itemId]) return this._bodySkinCache[itemId]; - const resp = await fetch( - _storysApiUrl(`/designer/skins/${itemId}`), - { - headers: { - Authorization: 'Bearer ' - + (localStorage.getItem('player_jwt') - || localStorage.getItem('Authorization') || ''), - }, - }, - ); - if (!resp.ok) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] _fetchBodySkinItem HTTP', resp.status); - return null; - } - const data = await resp.json(); - const item = data.item || data; - this._bodySkinCache[itemId] = item; - return item; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] _fetchBodySkinItem failed:', e); - return null; - } - } - - /** Загрузить GLB-модель персонажа и его анимации. */ - async _loadPlayerModel() { - // eslint-disable-next-line no-console - console.log(`[PlayerController] _loadPlayerModel start, modelTypeId=${this._modelTypeId}`); - const source = await this._resolveModelSource(); - // eslint-disable-next-line no-console - console.log(`[PlayerController] _resolveModelSource → ${source ? source.file : 'NULL'}`); - if (!source) return; - if (!this._active) return; - - // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш - // ModelManager. Если бы мы использовали тот же AssetContainer - // что и зомби (через _loadPrototype), повторный - // instantiateModelsToScene давал меши с битыми материалами. - // Babylon HTTP-кэш всё равно убирает сетевые запросы. - // - // ВАЖНО (2026-05-27): file_path для дизайнерских аватаров приходит - // как '/api-storys/...' — на проде player.rublox.pro это даст 404 - // (там нет такого пути). Конвертируем в абсолютный URL minecraftia. - let absFile = source.file; - if (absFile && absFile.startsWith('/api-storys/')) { - const isDev = (typeof window !== 'undefined' - && (window.location.hostname === 'localhost' - || window.location.hostname === '127.0.0.1')); - if (!isDev) { - absFile = 'https://minecraftia-school.ru' + absFile; - } - } - let rootUrl, filename; - if (source.isDataUrl) { - // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' - // и filename=data:... с подсказкой расширения через 5-й аргумент. - rootUrl = ''; - filename = absFile; - } else { - const lastSlash = absFile.lastIndexOf('/'); - rootUrl = absFile.substring(0, lastSlash + 1); - filename = absFile.substring(lastSlash + 1); - } - // eslint-disable-next-line no-console - console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`); - // Прогресс-индикатор для больших GLB (некоторые дизайнерские - // аватары до 60 МБ — на медленной сети идут минутами, без прогресса - // выглядит как зависание). Публикуем в глобал чтобы PreviewRoute - // мог его показать. - try { window.__playerLoadProgress = { loaded: 0, total: 0 }; } catch (e) {} - const onProgress = (evt) => { - try { - if (evt && evt.lengthComputable) { - window.__playerLoadProgress = { - loaded: evt.loaded, total: evt.total, - }; - const pct = ((evt.loaded / evt.total) * 100).toFixed(0); - // eslint-disable-next-line no-console - console.log(`[PlayerController] GLB загрузка: ${pct}% ` - + `(${(evt.loaded / 1024 / 1024).toFixed(1)}/` - + `${(evt.total / 1024 / 1024).toFixed(1)} МБ)`); - } - } catch (e) {} - }; - let container; - try { - container = await SceneLoader.LoadAssetContainerAsync( - rootUrl, filename, this.scene, onProgress, - source.isDataUrl ? '.glb' : undefined, - ); - try { window.__playerLoadProgress = null; } catch (e) {} - } catch (e) { - try { window.__playerLoadProgress = null; } catch (e2) {} - // eslint-disable-next-line no-console - console.error('[PlayerController] failed to load model:', e); - return; - } - try { - if (!this._active) { - try { container.dispose(); } catch (e) {} - return; - } - // Создаём корневой узел и инстанцируем модель туда - const root = new TransformNode('playerModel', this.scene); - // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока). - // - R15-скины ('skin_*'): фиксированный 0.301 — модели - // нормализованы к 5.98 ед пайплайном auto_rig_bacon - // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах - // с торчащими волосами/плащами (как у bacon-hair). - // - Kenney-модели: старый 0.72. - // - overrides.scale_mult — per-skin множитель из манифеста. - // Non-humanoid скины (животное/машина/еда) масштабируются иначе: - // базовый размер из манифеста (scale), без фикс-0.301. - const isNonHumanoid = source.kind === 'non-humanoid-mesh' - || source.kind === 'non-humanoid-rigged'; - let modelScale; - if (isNonHumanoid) { - modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0; - } else { - modelScale = source.isR15 ? 0.301 : this._modelScale; - const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; - modelScale *= scaleMult; - } - root.scaling = new Vector3(modelScale, modelScale, modelScale); - if (source.rotationYOffset) root.rotation.y = source.rotationYOffset; - const inst = container.instantiateModelsToScene( - (name) => `player_${name}`, - /*cloneAnimations*/ true, - { doNotInstantiate: false } - ); - for (const r of inst.rootNodes) { - r.parent = root; - // Фолбэк для GLB с face_dir_invert (старый Blender→glTF - // конвейер экспортировал лицом в -Z). После 2026-05-27 - // запеченные через bake_avatars_rotate.sh аватары флаг не - // требуют, но механизм оставлен для будущих кривых загрузок. - if (source.overrides && source.overrides.face_dir_invert) { - r.rotation.y = (r.rotation.y || 0) + Math.PI; - } - } - this._modelRoot = root; - this._modelKind = source.kind || 'r15'; - // hipHeight: на сколько центр модели поднят от «низа ног». - this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null; - - // Non-humanoid: нормализуем размер и опускаем модель на «ноги». - if (isNonHumanoid) { - this._setupNonHumanoidModel(root, modelScale, source); - } - - // === R15-скин: детекция скелета === - // R15-скины приходят с встроенным скелетом Mixamo. Babylon - // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер - // и, если скелет валидный, помечаем _isR15 + создаём аниматор. - this._isR15 = false; - this._r15Skeleton = null; - this._r15Animator = null; - this._skinOverrides = source.overrides || {}; - // eslint-disable-next-line no-console - console.log('[PlayerController] _loadPlayerModel: file=' + source.file - + ' isR15=' + source.isR15 - + ' inst.skeletons=' + ((inst.skeletons || []).length) - + ' rootNodes=' + (inst.rootNodes || []).length); - if (source.isR15) { - // Скелет ищем в нескольких местах: inst.skeletons (норма), - // container.skeletons (иногда не клонируется), на мешах - // модели (skeleton-property). Берём первый найденный. - let sk = (inst.skeletons && inst.skeletons[0]) || null; - if (!sk && container.skeletons && container.skeletons.length > 0) { - sk = container.skeletons[0]; - } - if (!sk) { - const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton); - if (meshWithSkel) sk = meshWithSkel.skeleton; - } - if (sk) { - // eslint-disable-next-line no-console - console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length - + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(',')); - const r15 = new R15Skeleton(sk); - if (r15.isValidR15()) { - this._r15Skeleton = r15; - this._isR15 = true; - this._r15Animator = new R15Animator(r15, this._skinOverrides); - // eslint-disable-next-line no-console - console.log('[PlayerController] R15-скин загружен:', - this._modelTypeId, '— костей:', r15.resolvedNames().length, - 'overrides:', JSON.stringify(this._skinOverrides)); - } else { - // eslint-disable-next-line no-console - console.warn('[PlayerController] R15-скин', this._modelTypeId, - '— скелет не прошёл валидацию. Зарезолвлено:', - r15.resolvedNames().join(','), - '| все кости скелета:', - (sk.bones || []).map(b => b.name).join(',')); - } - } else { - // eslint-disable-next-line no-console - console.warn('[PlayerController] R15-скин', this._modelTypeId, - '— нет скелета в glb'); - } - } - - // Собираем все mesh-чилдрены (для toggle visibility в 1st person) - this._modelMeshes = root.getChildMeshes(false); - // Игрок не должен ловить свой raycast → отключаем pickable - for (const m of this._modelMeshes) { - m.isPickable = false; - if (m.alwaysSelectAsActiveMesh !== undefined) { - m.alwaysSelectAsActiveMesh = true; - } - // Тени: персонаж принимает тени от мира (тень дерева - // ложится на тело игрока) и сам отбрасывает тень — caster - // регистрируется отдельно через _scene3d.addShadowCaster. - m.receiveShadows = true; - } - // Игрок ОТБРАСЫВАЕТ тень — регистрируем mesh-части как shadow casters. - try { - if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') { - for (const m of this._modelMeshes) { - this._scene3d.addShadowCaster(m); - } - } - } catch (e) { /* ignore */ } - // У Kenney character имена `arm-left`/`arm-right` соответствуют - // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right). - // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его - // правая рука у нас слева на экране. - // - // Берём именно arm-right (его правую) — это та рука куда логично - // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4. - this._rightArmMeshes = []; - for (const m of this._modelMeshes) { - const n = (m.name || '').toLowerCase(); - // Берём именно «right» — настоящая правая рука персонажа - if (n === 'player_arm-right' || n.endsWith('arm-right') - || n.includes('right-arm') || n.includes('rightarm') - || n.includes('right-hand') || n.includes('hand-right')) { - this._rightArmMeshes.push(m); - break; - } - } - // Fallback: если по имени не нашли — берём левую по позиции (x<0) - if (this._rightArmMeshes.length === 0) { - let bestMesh = null; - let bestX = Infinity; - for (const m of this._modelMeshes) { - if (!m.position) continue; - const n = (m.name || '').toLowerCase(); - if (n.includes('leg') || n.includes('foot') || n.includes('head') - || n.includes('hat') || n.includes('torso') || n.includes('root')) continue; - const px = m.position.x; - const py = m.position.y; - if (py < 0.8 || py > 2.2) continue; - if (px >= -0.05) continue; // ищем X<0 - if (px < bestX) { bestX = px; bestMesh = m; } - } - if (bestMesh) this._rightArmMeshes = [bestMesh]; - } - const arm = this._rightArmMeshes[0]; - if (arm) { - this._rightArmX = arm.position.x; - this._rightArmY = arm.position.y; - this._rightArmZ = arm.position.z; - } - - // Анимации. - // R15-скины не содержат AnimationGroups (анимируются процедурно - // через R15Animator в _tick). Kenney-модели — наоборот, имеют - // встроенные AnimationGroups (idle/walk/sprint/jump). - this._animations = {}; - if (!this._isR15) { - const groups = inst.animationGroups || []; - for (const g of groups) { - const name = (g.name || '').toLowerCase(); - if (name.includes('idle')) this._animations.idle = g; - else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g; - else if (name.includes('walk')) this._animations.walk = g; - else if (name.includes('jump')) this._animations.jump = g; - g.stop(); - } - this._playAnim('idle'); - } - // Применяем текущий camera-mode (показать/скрыть модель) - this._applyCameraMode(); - - // Подфаза 3.3 — создаём AccessoryManager после успешной загрузки - // тела. Если был старый (после смены скина) — его аксессуары - // уже задиспозены вместе со старым modelRoot, просто - // создаём заново. - this._accessoryManager = new AccessoryManager( - this.scene, this._r15Skeleton, this._modelRoot, - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error('[PlayerController] failed to load model:', e); - } - } - - /** - * Настройка non-humanoid модели (животное/машина/еда): нормализация - * размера и опускание на «низ ног». В отличие от R15 (нормализованы - * пайплайном), эти модели произвольного размера, поэтому считаем bbox. - * - * Локальные координаты root: модель должна стоять так, чтобы её низ был - * на y=0 (там «ноги»). PlayerController позиционирует root в точке - * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю. - */ - _setupNonHumanoidModel(root, scaleApplied, source) { - try { - // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ - // применения scaling root'а. Babylon refreshBoundingInfo нужен после - // инстансинга. - const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0); - if (!meshes.length) return; - root.computeWorldMatrix(true); - let minY = Infinity, maxY = -Infinity, maxDim = 0; - let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; - for (const m of meshes) { - m.computeWorldMatrix(true); - // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного - // скелета/морфов; без него minimumWorld у инстансов часто нулевой - // или из исходной позы → центр считался неверно (баг пришельца/робота). - try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} } - const bi = m.getBoundingInfo(); - const bb = bi.boundingBox; - const lo = bb.minimumWorld, hi = bb.maximumWorld; - if (!lo || !hi) continue; - minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y); - minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x); - minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z); - } - if (!Number.isFinite(minX) || !Number.isFinite(minY)) return; - const h = maxY - minY; - const w = maxX - minX; - const d = maxZ - minZ; - maxDim = Math.max(h, w, d); - // === Центрирование модели через pivot-node === - // Многие Kenney-модели имеют origin НЕ в геометрическом центре - // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого - // origin (баг пришельца/робота). Ручной сдвиг детей с делением на - // scaleApplied неверен если у детей свой scale/rotation. Надёжно: - // вставляем промежуточный pivot между root и моделью и смещаем pivot - // на -localCenter (через инверсию world-матрицы root — точно при - // любом scale/rotation). - const worldCenter = new Vector3( - (minX + maxX) / 2, // центр X - minY, // низ Y (модель «садится» на ноги) - (minZ + maxZ) / 2 // центр Z - ); - // world-центр → локальные координаты root - const invRoot = root.getWorldMatrix().clone().invert(); - const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot); - const pivot = new TransformNode('playerModelPivot', this.scene); - pivot.parent = root; - pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z); - // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot. - for (const ch of root.getChildren().slice()) { - if (ch === pivot) continue; - ch.parent = pivot; - } - // Сохраняем размеры для настраиваемого AABB и камеры. - // hipHeight из манифеста — приоритетно; иначе берём низ модели. - this._nonHumanoidBox = { w, h, d }; - this._modelBaseHeight = h; - // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды). - // Ограничиваем разумными пределами чтобы не проваливаться/застревать. - this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2)); - this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2)); - const halfH = Math.max(0.3, Math.min(1.0, h / 2)); - this.HALF_H = halfH; - this.HALF_H_NORMAL = halfH; - this.EYE_HEIGHT = halfH * 0.7; - // eslint-disable-next-line no-console - console.log('[PlayerController] non-humanoid setup:', this._modelTypeId, - 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2), - 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2)); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] _setupNonHumanoidModel failed:', e); - } - } - - /** - * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать - * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при - * беге + наклон в воздухе. Вызывается каждый кадр из _tick. - * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel). - */ - _animateNonHumanoidMesh(dt) { - const root = this._modelRoot; - if (!root) return; - const t = (typeof performance !== 'undefined' && performance.now) - ? performance.now() / 1000 : Date.now() / 1000; - const speed = this._lastFrameSpeed || 0; - // Базовое вращение по yaw уже выставляет _tick (он крутит модель под - // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt - // поверх — храним их в отдельных полях, чтобы _tick их не перетёр. - let bobY = 0, tiltX = 0; - if (!this._isGrounded) { - tiltX = 0.2; // в воздухе — нос вверх - } else if (speed > 0.1) { - const bobFreq = 8 * Math.min(2, speed / 4); - bobY = Math.sin(t * bobFreq) * 0.06; - tiltX = Math.min(speed * 0.04, 0.13); - } else { - bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое - } - // Применяем поверх позиции, которую _tick уже выставил в root.position.y. - root.position.y += bobY; - // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом. - root.rotation.x = tiltX; - } - - // ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ── - - /** - * Надеть аксессуар. item — RubloxItem.serialize с полем attachment. - * Возвращает Promise (handle.dispose() снимает). - * Если в этом слоте уже надето что-то — старое снимается автоматом. - */ - async equipAccessory(item) { - if (!this._accessoryManager) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] equipAccessory: model not loaded yet'); - return null; - } - return this._accessoryManager.attach(item); - } - - /** Снять аксессуар из слота (hat/tool/hair/face/...). */ - unequipSlot(slot) { - if (this._accessoryManager) this._accessoryManager.detachSlot(slot); - } - - /** Снять все аксессуары. */ - unequipAll() { - if (this._accessoryManager) this._accessoryManager.detachAll(); - } - - /** Геттер для прямого доступа (used by calibration UI / DevTools). */ - getAccessoryManager() { - return this._accessoryManager || null; - } - - /** AABB игрока пересекает хотя бы один блок-воду. */ - _isInWater() { - const bm = this._scene3d?.blockManager; - if (!bm) return false; - // FAST PATH: если на сцене нет водных блоков — точно не в воде. - // Большинство карт (зомби-остров, любые «суховые») — без воды, - // и тройной цикл ниже бесполезно тратит время каждый кадр. - if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; - const px = this._pos.x, py = this._pos.y, pz = this._pos.z; - const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D; - // Проверяем клетки которые AABB перекрывает - const gxMin = Math.floor(px - hw + 0.5); - const gxMax = Math.floor(px + hw + 0.5); - const gzMin = Math.floor(pz - hd + 0.5); - const gzMax = Math.floor(pz + hd + 0.5); - const gyMin = Math.floor(py - hh); - const gyMax = Math.floor(py + hh); - for (let gx = gxMin; gx <= gxMax; gx++) { - for (let gy = gyMin; gy <= gyMax; gy++) { - for (let gz = gzMin; gz <= gzMax; gz++) { - const m = bm.blocks.get(`${gx},${gy},${gz}`); - if (m?.metadata?.isWater) return true; - } - } - } - return false; - } - - /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */ - _isSubmerged() { - const bm = this._scene3d?.blockManager; - if (!bm) return false; - // FAST PATH: нет воды на сцене — не утопаем. - if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; - // Проверяем «голову» — точку чуть ниже верха AABB - const headY = this._pos.y + this.HALF_H - 0.1; - const gx = Math.round(this._pos.x); - const gy = Math.floor(headY); - const gz = Math.round(this._pos.z); - const m = bm.blocks.get(`${gx},${gy},${gz}`); - return !!m?.metadata?.isWater; - } - - /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */ - _playFootstep() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - - const now = ctx.currentTime; - const duration = 0.08; - - // Источник — короткий шумовой буфер - const sampleRate = ctx.sampleRate; - const length = Math.floor(sampleRate * duration); - const buffer = ctx.createBuffer(1, length, sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < length; i++) { - data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум - } - const src = ctx.createBufferSource(); - src.buffer = buffer; - - // Lowpass для тяжёлого «тук» вместо высокого «шшш» - const lowpass = ctx.createBiquadFilter(); - lowpass.type = 'lowpass'; - lowpass.frequency.value = 350; - lowpass.Q.value = 1.5; - - // Envelope (быстрая атака, быстрое затухание) - const gain = ctx.createGain(); - gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(0.7, now + 0.005); - gain.gain.exponentialRampToValueAtTime(0.001, now + duration); - - src.connect(lowpass).connect(gain).connect(ctx.destination); - src.start(now); - src.stop(now + duration); - } catch (e) { - // ignore — звук не критичен - } - } - - /** - * Звук прыжка — мягкий «boing» из двух слоёв: - * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами» - * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма» - * Гораздо приятнее старого квадратного восходящего тона. - */ - /** - * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation. - * Работает только для R15-скинов (Kenney-модели эмоций не имеют). - */ - playEmote(name) { - if (this._isR15 && this._r15Animator) { - return this._r15Animator.playEmote(name); - } - return false; - } - - /** Прервать текущую эмоцию персонажа. */ - stopEmote() { - if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote(); - } - - /** - * Проиграть кастомный emote из GLB-spec (см. EmoteGlbParser). - * Используется в preview-режиме теста дизайнерских emote. - */ - playCustomEmote(spec) { - if (this._isR15 && this._r15Animator) { - return this._r15Animator.playCustomEmote(spec); - } - return false; - } - - _playJumpSound() { - // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок - // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся. - if (typeof this._onJump === 'function') { - try { this._onJump(); } catch (e) { /* ignore */ } - } - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const now = ctx.currentTime; - const out = ctx.destination; - - // Слой 1: низкий thump - const thumpDur = 0.07; - const thumpOsc = ctx.createOscillator(); - thumpOsc.type = 'sine'; - thumpOsc.frequency.setValueAtTime(110, now); - thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur); - const thumpGain = ctx.createGain(); - thumpGain.gain.setValueAtTime(0, now); - thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005); - thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur); - thumpOsc.connect(thumpGain).connect(out); - thumpOsc.start(now); - thumpOsc.stop(now + thumpDur + 0.02); - - // Слой 2: «boing» — pitch-down sine - const boingDur = 0.18; - const boingOsc = ctx.createOscillator(); - boingOsc.type = 'sine'; - boingOsc.frequency.setValueAtTime(720, now + 0.005); - boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur); - const boingGain = ctx.createGain(); - boingGain.gain.setValueAtTime(0, now + 0.005); - boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02); - boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur); - // Лёгкое vibrato чтобы было «живее» - const lfo = ctx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.value = 14; - const lfoGain = ctx.createGain(); - lfoGain.gain.value = 18; // ±18 Hz - lfo.connect(lfoGain).connect(boingOsc.frequency); - boingOsc.connect(boingGain).connect(out); - boingOsc.start(now + 0.005); - lfo.start(now + 0.005); - boingOsc.stop(now + 0.005 + boingDur + 0.02); - lfo.stop(now + 0.005 + boingDur + 0.02); - } catch (e) { /* ignore */ } - } - - /** Звук «бульк» при входе/выходе из воды. */ - _playSplashSound() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const now = ctx.currentTime; - const duration = 0.35; - - // Шум с быстро падающим highpass — звук «всплеска» - const length = Math.floor(ctx.sampleRate * duration); - const buf = ctx.createBuffer(1, length, ctx.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < length; i++) { - data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2; - } - const src = ctx.createBufferSource(); - src.buffer = buf; - - const bp = ctx.createBiquadFilter(); - bp.type = 'bandpass'; - bp.frequency.setValueAtTime(2000, now); - bp.frequency.exponentialRampToValueAtTime(400, now + duration); - bp.Q.value = 1.5; - - const g = ctx.createGain(); - g.gain.setValueAtTime(0, now); - g.gain.linearRampToValueAtTime(0.6, now + 0.01); - g.gain.exponentialRampToValueAtTime(0.001, now + duration); - - src.connect(bp).connect(g).connect(ctx.destination); - src.start(now); - src.stop(now + duration); - } catch (e) { /* ignore */ } - } - - _playAnim(name) { - if (this._currentAnim === name) return; - const target = this._animations[name]; - if (!target) { - // если нужной нет — пробуем idle как fallback - if (name !== 'idle' && this._animations.idle) { - return this._playAnim('idle'); - } - return; - } - // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ === - // Помогает дебажить вибрацию ног на склонах: если в логе видно - // частое мерцание walk↔jump или idle↔walk — значит onGround или - // isMoving скачет каждые несколько кадров. - const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false }; - console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} ` - + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`); - // Стопим текущую - if (this._currentAnim && this._animations[this._currentAnim]) { - this._animations[this._currentAnim].stop(); - } - target.start(/*loop*/ true, /*speed*/ 1); - this._currentAnim = name; - } - - /** - * Остановить режим игры. Освобождает все ресурсы. - */ - stop() { - if (!this._active) return; - this._active = false; - - if (this._beforeRender) { - this.scene.unregisterBeforeRender(this._beforeRender); - this._beforeRender = null; - } - for (const { target, type, fn, opts } of this._listeners) { - target.removeEventListener(type, fn, opts); - } - this._listeners = []; - - if (document.pointerLockElement === this.canvas) { - document.exitPointerLock(); - } - - // Останавливаем все анимации - for (const g of Object.values(this._animations)) { - try { g.stop(); } catch (e) { /* ignore */ } - } - this._animations = {}; - this._currentAnim = null; - - // Удаляем якорь оружия - if (this._weaponAnchor) { - try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ } - this._weaponAnchor = null; - } - // Сбрасываем все override'ы вращения - if (this._meshRotationOverrides) { - this._meshRotationOverrides.clear(); - } - // Сброс R15-состояния - if (this._r15BoneOverrides) this._r15BoneOverrides.clear(); - this._r15Animator = null; - this._r15Skeleton = null; - this._isR15 = false; - - // Удаляем модель - if (this._modelRoot) { - for (const m of this._modelMeshes) { - try { m.dispose(); } catch (e) { /* ignore */ } - } - try { this._modelRoot.dispose(); } catch (e) { /* ignore */ } - this._modelRoot = null; - this._modelMeshes = []; - } - - if (this.camera) { - this.camera.dispose(); - this.camera = null; - } - - if (this._audioCtx) { - try { this._audioCtx.close(); } catch (e) { /* ignore */ } - this._audioCtx = null; - } - } - - isActive() { - return this._active; - } - - // === ВНУТРЕННЕЕ === - - /** Позиция камеры в мире (зависит от режима first/third/front). */ - _computeCameraPos() { - // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up - // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем» - // высоту чтобы камера не дёргалась рывком при step-up. - const visY = this._pos.y - this._stepUpVisualOffset; - if (this._cameraMode === 'first') { - return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z); - } - if (this._cameraMode === 'sideview') { - // Кубикон Dash: камера сбоку, фиксированный yaw на куб. - // Игрок движется по +X, камера в -Z от него (смотрит на +Z). - // С этого ракурса +X на экране = вправо (как в Geometry Dash). - // Лёгкое смещение camera по X влево от куба — игрок в левой - // трети кадра, впереди видно больше уровня. - return new Vector3( - this._pos.x - 1.5, - visY + SIDEVIEW_HEIGHT, - this._pos.z - SIDEVIEW_DIST - ); - } - // forward — направление «куда смотрит игрок» с учётом yaw и pitch - const cosP = Math.cos(this._pitch); - const fx = Math.sin(this._yaw) * cosP; - const fy = -Math.sin(this._pitch); - const fz = Math.cos(this._yaw) * cosP; - const dist = this._thirdDistance; - - // Точка «глаз» игрока — отсюда пускаем луч к запланированной - // позиции камеры и сокращаем дистанцию если упёрлись в стену. - const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET; - - if (this._cameraMode === 'third') { - const desired = new Vector3( - this._pos.x - fx * dist, - eyeY - fy * dist, - this._pos.z - fz * dist - ); - return this._clampCameraToWorld( - this._pos.x, eyeY, this._pos.z, desired - ); - } - // 'front' — спереди игрока, направлена назад (на лицо) - const desiredFront = new Vector3( - this._pos.x + fx * dist, - eyeY + fy * dist, - this._pos.z + fz * dist - ); - return this._clampCameraToWorld( - this._pos.x, eyeY, this._pos.z, desiredFront - ); - } - - /** - * Не позволяет камере «проходить» сквозь стены/блоки/примитивы. - * Пускает луч от глаз игрока до запланированной позиции камеры. - * Если на пути есть препятствие — возвращает точку чуть ближе - * (hit.distance - PADDING), чтобы камера прижалась к стене. - * - * Игнорирует: - * - меши без metadata (вспомогательная техника редактора), - * - триггеры (canCollide===false), - * - саму модель игрока, - * - debris/particles. - */ - _clampCameraToWorld(ex, ey, ez, desired) { - if (!this.scene) return desired; - // Вектор от глаз до желаемой камеры. - const dx = desired.x - ex; - const dy = desired.y - ey; - const dz = desired.z - ez; - const len = Math.sqrt(dx * dx + dy * dy + dz * dz); - if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт - const dir = new Vector3(dx / len, dy / len, dz / len); - const origin = new Vector3(ex, ey, ez); - const ray = new Ray(origin, dir, len); - - const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась» - const playerRoot = this._modelRoot; - - const pickPred = (mesh) => { - if (!mesh) return false; - if (!mesh.isEnabled || !mesh.isEnabled()) return false; - // Прозрачно-визуальные и technical meshes — пропускаем. - if (mesh.isPickable === false) return false; - const md = mesh.metadata || {}; - // Триггеры/невидимки/скриптовые маркеры — не блокируют. - if (md.canCollide === false) return false; - if (md._isTriggerHelper) return false; - // Модель игрока (камера не должна цепляться за собственный меш). - if (playerRoot) { - let n = mesh; - while (n) { - if (n === playerRoot) return false; - n = n.parent; - } - } - // Жидкости (вода/лава) — не блокируют камеру. - if (md._liquidProxy) return false; - return true; - }; - - let hit = null; - try { - hit = this.scene.pickWithRay(ray, pickPred); - } catch (e) { - return desired; - } - if (!hit || !hit.hit || hit.distance >= len - 0.01) { - return desired; - } - // Сокращаем дистанцию. - const clampedLen = Math.max(0.3, hit.distance - PADDING); - return new Vector3( - ex + dir.x * clampedLen, - ey + dir.y * clampedLen, - ez + dir.z * clampedLen - ); - } - - // ===== Управление камерой из скрипта (Фаза 5.7) ===== - - /** Установить угол обзора камеры (FOV) в градусах. */ - setCameraFov(degrees) { - if (!this.camera) return; - const d = Number(degrees); - if (!Number.isFinite(d) || d < 10 || d > 130) return; - this.camera.fov = d * Math.PI / 180; - } - - /** - * Привязать камеру к объекту — она смотрит на него. - * getTarget — функция, возвращающая {x,y,z} цели. - * opts: { distance, height } — отступ камеры от цели. - */ - cameraFocusOn(getTarget, opts = {}) { - if (typeof getTarget !== 'function') return; - this._cameraOverride = { - mode: 'focus', - getTarget, - distance: Number.isFinite(opts.distance) ? opts.distance : 8, - height: Number.isFinite(opts.height) ? opts.height : 4, - }; - } - - /** - * Катсцена — плавный пролёт камеры по точкам. - * points — массив {x,y,z} позиций камеры. - * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию). - * segDuration — секунд на отрезок между точками. - * onDone — колбэк по завершении. - */ - cameraCutscene(points, lookAt, segDuration, onDone) { - if (!Array.isArray(points) || points.length < 2) return; - this._cameraOverride = { - mode: 'cutscene', - points, - lookAt: Array.isArray(lookAt) ? lookAt : [], - segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2, - t: 0, // время от начала - seg: 0, // текущий отрезок - onDone: typeof onDone === 'function' ? onDone : null, - }; - } - - /** Вернуть камеру под управление игрока. */ - cameraReset() { - this._cameraOverride = null; - } - - /** Применить активный режим камеры скрипта (вызывается в _tick). */ - _applyCameraOverride(dt) { - const o = this._cameraOverride; - if (!o || !this.camera) return; - if (o.mode === 'focus') { - const t = o.getTarget(); - if (!t) return; - // Камера позади-сверху цели, смотрит на неё. - this.camera.position.set(t.x, t.y + o.height, t.z + o.distance); - this.camera.setTarget(new Vector3(t.x, t.y, t.z)); - } else if (o.mode === 'cutscene') { - // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB) - // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается» - // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт - // ровно свою длительность независимо от лагов. - o.t += Math.min(dt, 1 / 30); - const segCount = o.points.length - 1; - // Прогресс по текущему отрезку [0..1]. - let local = o.t / o.segDuration; - let seg = Math.floor(o.t / o.segDuration); - if (seg >= segCount) { - // Катсцена завершена — встаём на последнюю точку. - const last = o.points[o.points.length - 1]; - this.camera.position.set(last.x, last.y, last.z); - const lookLast = o.lookAt[o.lookAt.length - 1]; - if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z)); - const cb = o.onDone; - this._cameraOverride = null; - if (cb) { try { cb(); } catch (e) { /* ignore */ } } - return; - } - local = local - seg; // дробная часть = прогресс отрезка - // Сглаживание (ease-in-out) — плавный пролёт. - const k = local < 0.5 - ? 2 * local * local - : 1 - Math.pow(-2 * local + 2, 2) / 2; - const a = o.points[seg], b = o.points[seg + 1]; - this.camera.position.set( - a.x + (b.x - a.x) * k, - a.y + (b.y - a.y) * k, - a.z + (b.z - a.z) * k, - ); - // Точка взгляда — интерполяция между соседними lookAt. - const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg]; - if (la && lb) { - this.camera.setTarget(new Vector3( - la.x + (lb.x - la.x) * k, - la.y + (lb.y - la.y) * k, - la.z + (lb.z - la.z) * k, - )); - } - } - } - - /** - * Применить текущий режим камеры: - * - В 1st person скрываем модель игрока (видим только сцену) - * - В 3rd person и front показываем - */ - _applyCameraMode() { - const visible = this._cameraMode !== 'first'; - for (const m of this._modelMeshes) { - m.setEnabled(visible); - } - // Сообщаем оружию что режим камеры сменился — чтобы перепарентить - // view-model (камера в 1-st, модель игрока в 3-rd). - if (this._scene3d?.weapons?.onCameraModeChange) { - this._scene3d.weapons.onCameraModeChange(this._cameraMode); - } - } - - /** - * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели. - * - * Установить override-rotation для именованного меша поверх анимации. - * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion, - * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет - * наши углы Эйлера. - * - * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...). - * Без префикса 'player_'. - * rotation — Vector3 углов Эйлера (rad). null → снять override. - * - * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты). - */ - setMeshRotationOverride(meshName, rotation) { - // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт - // этот метод для позы/замаха — переадресуем на override кости. - // R15Animator каждый кадр ставит rest+анимацию; override кости - // применяется поверх в _applyR15BoneOverrides() после update(). - if (this._isR15) { - if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map(); - // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость. - const lower = (meshName || '').toLowerCase(); - const logical = (lower.includes('right')) ? 'RightUpperArm' - : (lower.includes('left')) ? 'LeftUpperArm' - : 'RightUpperArm'; - if (rotation == null) { - this._r15BoneOverrides.delete(logical); - } else { - this._r15BoneOverrides.set(logical, - rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); - } - return; - } - if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map(); - if (rotation == null) { - this._meshRotationOverrides.delete(meshName); - } else { - this._meshRotationOverrides.set(meshName, - rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); - } - } - - /** - * Применить override-повороты костей R15 поверх процедурной анимации. - * Вызывается после R15Animator.update(). Используется WeaponSystem - * для позы руки с оружием / melee-замаха. - */ - _applyR15BoneOverrides() { - const map = this._r15BoneOverrides; - if (!map || map.size === 0 || !this._r15Skeleton) return; - for (const [logical, rot] of map.entries()) { - const bone = this._r15Skeleton.resolveBone(logical); - if (!bone) continue; - // Override задаётся как абсолютный локальный поворот кости - // (Эйлер). Перекрывает то, что поставил аниматор этим кадром. - const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z); - bone.setRotationQuaternion(q, Space.LOCAL); - } - } - - /** Получить меш модели по короткому имени (без префикса 'player_'). */ - getModelMesh(meshName) { - if (!this._modelMeshes) return null; - const target = `player_${meshName}`; - return this._modelMeshes.find(m => m.name === target) || null; - } - - /** - * Применить все активные override'ы. Вызывается каждый кадр в _tick - * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после - * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его - * каждый кадр и пишем в .rotation. - */ - _applyMeshRotationOverrides() { - const map = this._meshRotationOverrides; - if (!map || map.size === 0) return; - for (const [meshName, rot] of map.entries()) { - const mesh = this.getModelMesh(meshName); - if (!mesh) continue; - if (mesh.rotationQuaternion) { - mesh.rotationQuaternion = null; - } - mesh.rotation.x = rot.x; - mesh.rotation.y = rot.y; - mesh.rotation.z = rot.z; - } - } - - /** - * Включить/выключить «позу с оружием». - * Делает 2 вещи независимо: - * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney). - * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает - * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0, - * и дуло автоматически смотрит вперёд персонажа. - * - * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять - * ориентацию повёрнутого меша руки. - */ - _updateExtendedArm(hasWeapon) { - // === R15-скин: якорь оружия на кости RightHand === - // У R15 нет меша-руки — есть кость. Якорь привязываем к кости - // через attachToBone: оружие следует за рукой при анимации. - if (this._isR15 && this._r15Skeleton) { - const showWeapon = hasWeapon && this._cameraMode !== 'first'; - if (showWeapon && !this._weaponAnchor) { - const handBone = this._r15Skeleton.resolveBone('RightHand'); - const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0]; - if (handBone && skinMesh) { - this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); - // attachToBone — якорь следует за костью каждый кадр. - this._weaponAnchor.attachToBone(handBone, skinMesh); - // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье. - this._weaponAnchor.position.set(0, 0.05, 0.1); - } - } - if (this._weaponAnchor) { - this._weaponAnchor.setEnabled(showWeapon); - } - return; - } - - const armMesh = this._rightArmMeshes?.[0]; - if (!armMesh) return; - const meshName = (armMesh.name || '').replace(/^player_/, ''); - const showWeapon = hasWeapon && this._cameraMode !== 'first'; - - // 1) Поза руки через override - if (showWeapon) { - this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0)); - } else { - this.setMeshRotationOverride(meshName, null); - } - - // 2) ChIstый якорь для оружия — TransformNode на плече персонажа, - // ориентация совпадает с _modelRoot (без поворотов). - if (showWeapon && !this._weaponAnchor && this._modelRoot) { - this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); - this._weaponAnchor.parent = this._modelRoot; - // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша. - // Плечо: y = origin + 0.7 (выше). - // X: сдвигаем чуть наружу (ещё правее). - const sx = -(armMesh.position?.x ?? -0.4) + 0.15; - const sy = (armMesh.position?.y ?? 1.1) + 0.7; - const sz = (armMesh.position?.z ?? 0) + 0.95; - this._weaponAnchor.position.set(sx, sy, sz); - } - if (this._weaponAnchor) { - this._weaponAnchor.setEnabled(showWeapon); - } - } - - getWeaponAnchor() { return this._weaponAnchor || null; } - - /** Цикл first ↔ third. */ - _toggleCameraMode() { - const idx = CAMERA_MODES.indexOf(this._cameraMode); - this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; - this._applyCameraMode(); - } - - /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус - * всегда лицом к камере, камера через плечо). - */ - setShiftLock(on) { - this._shiftLock = !!on; - if (this._shiftLock) { - // Запросить pointer-lock — курсор в центре - this._requestPointerLockSafe(); - } else { - // Снять lock если он есть и нет других причин держать (first/sideview) - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' - ); - if (!needPermLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - } - this._applyCursorVisibility?.(); - } - isShiftLock() { return !!this._shiftLock; } - - /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc). - * Не блокирует Esc/Tab/Enter (нужны для GUI). - * Также сбрасывает накопленные клавиши чтобы движение остановилось. */ - setInputBlocked(blocked) { - this._inputBlocked = !!blocked; - if (this._inputBlocked) { - try { this._codes?.clear(); } catch (e) {} - this._shift = false; - // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры» - try { - if (document.pointerLockElement === this.canvas) document.exitPointerLock(); - } catch (e) {} - } - } - isInputBlocked() { return !!this._inputBlocked; } - - /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */ - setCameraFrozen(frozen) { - this._cameraFrozen = !!frozen; - } - isCameraFrozen() { return !!this._cameraFrozen; } - - /** Задача 04: снимок состояния камеры — для восстановления после модала. */ - captureCameraState() { - return { - yaw: this._yaw, - pitch: this._pitch, - cameraMode: this._cameraMode, - thirdDistance: this._thirdDistance, - fov: this.scene?.activeCamera?.fov, - playerPos: this._pos ? { - x: this._pos.x, y: this._pos.y, z: this._pos.z - } : null, - }; - } - - /** Задача 04: восстановить состояние камеры из снимка. */ - restoreCameraState(s) { - if (!s) return; - if (Number.isFinite(s.yaw)) this._yaw = s.yaw; - if (Number.isFinite(s.pitch)) this._pitch = s.pitch; - if (s.cameraMode) { - this._cameraMode = s.cameraMode; - try { this._applyCameraMode?.(); } catch (e) {} - } - if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance; - if (Number.isFinite(s.fov) && this.scene?.activeCamera) { - this.scene.activeCamera.fov = s.fov; - } - } - - /** Задача 04: камера-фокус на reference (cube/npc/cam-target). - * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}. - * Использует уже существующий механизм camera.focus в GameRuntime, но - * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель, - * и зум на distance. */ - focusOnTarget(ref, opts) { - opts = opts || {}; - const distance = Number.isFinite(opts.distance) ? opts.distance : 8; - const height = Number.isFinite(opts.height) ? opts.height : 3; - const fov = Number.isFinite(opts.fov) ? opts.fov : null; - let target = null; - if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) { - target = ref; - } else { - const m = this._resolveTargetMesh(ref); - if (m) { - const p = m.getAbsolutePosition?.() || m.position; - target = { x: p.x, y: p.y, z: p.z }; - } - } - if (!target) return; - // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target - // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch. - if (!this._pos) return; - const dx = target.x - this._pos.x; - const dz = target.z - this._pos.z; - const dy = target.y - this._pos.y; - const horiz = Math.hypot(dx, dz); - this._yaw = Math.atan2(dx, dz); - this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz))); - this._thirdDistance = distance; - if (this._cameraMode !== 'third') { - this._cameraMode = 'third'; - try { this._applyCameraMode?.(); } catch (e) {} - } - if (fov && this.scene?.activeCamera) { - this.scene.activeCamera.fov = fov * Math.PI / 180; - } - } - - _resolveTargetMesh(ref) { - if (!ref) return null; - if (ref.getScene && typeof ref.getScene === 'function') return ref; - const sc = this._scene3d || this.scene3d; - const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); - if (!idStr || !sc) return null; - const tries = [ - () => sc.primitiveManager?.getMesh?.(idStr), - () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0], - () => sc.scene?.getMeshByName?.(idStr), - () => sc.npcManager?.getMeshes?.(idStr)?.[0], - ]; - for (const fn of tries) { - try { const r = fn(); if (r) return r; } catch (e) {} - } - return null; - } - - /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */ - setCameraZoom(distance) { - const d = Number(distance); - if (!Number.isFinite(d)) return; - this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, - Math.min(this.THIRD_DISTANCE_MAX, d)); - // Авто-переход third↔first если пересекли порог - if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD - && this._cameraMode === 'third') { - this._cameraMode = 'first'; - this._applyCameraMode?.(); - this._requestPointerLockSafe(); - } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD - && this._cameraMode === 'first' && !this._lockFirstPerson) { - this._cameraMode = 'third'; - this._applyCameraMode?.(); - if (!this._shiftLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - } - } - /** Установить границы зума колеса. */ - setCameraZoomLimits(min, max) { - const mn = Number(min), mx = Number(max); - if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn; - if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx; - // Перекламп текущей дистанции - this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, - Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance)); - } - - _setupInput() { - const canvas = this.canvas; - - // Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock. - const needPermLock = () => ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - - const onCanvasClick = () => { - // В UI-режиме клик не перехватывает мышь. - if (this._uiCursorMode) return; - if (!this._active) return; - // Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся - // свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов - // где курсор постоянно скрыт, и только если lock был снят. - if (!needPermLock()) return; - if (document.pointerLockElement !== canvas) { - try { - const p = canvas.requestPointerLock?.(); - if (p && typeof p.catch === 'function') p.catch(() => {}); - } catch (e) { /* ignore */ } - } - }; - 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-игр) === - const onCanvasMouseDown = (e) => { - if (!this._uiCursorMode) return; - if (typeof this._uiMouseDownCb !== 'function') return; - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { - try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ } - } - }; - const onCanvasMouseUp = (e) => { - if (!this._uiCursorMode) return; - if (typeof this._uiMouseUpCb !== 'function') return; - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ } - }; - canvas.addEventListener('mousedown', onCanvasMouseDown); - // mouseup ловим на document — мышь могла уйти за пределы канваса - document.addEventListener('mouseup', onCanvasMouseUp); - - const onMouseMove = (e) => { - // === UI-режим: транслируем нормализованные [0..1] координаты === - // подписчику (Worker через GameRuntime). Используется для drag-игр - // типа Дальгона. - if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') { - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - // Кидаем только если внутри канваса - if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { - try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ } - } - } - if (document.pointerLockElement !== canvas) return; - // Кубикон Dash: в sideview мышь не вращает камеру. - if (this._cameraMode === 'sideview') return; - // Задача 04: модал с freezeCamera — мышь не вращает. - if (this._cameraFrozen) return; - this._yaw += e.movementX * this.MOUSE_SENSITIVITY; - // _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y. - const pitchSign = this._invertCamera ? -1 : 1; - this._pitch += e.movementY * this.MOUSE_SENSITIVITY * pitchSign; - const lim = Math.PI / 2 - 0.05; - if (this._pitch > lim) this._pitch = lim; - if (this._pitch < -lim) this._pitch = -lim; - }; - document.addEventListener('mousemove', onMouseMove); - - // Задача 02: колесо = зум third-камеры с авто-переходом third↔first. - const onWheel = (e) => { - if (!this._active) return; - if (this._cameraFrozen) { e.preventDefault(); return; } // модал - if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD - // В first зум наружу возвращает в third (если не lockfirst). - 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_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(); - }; - canvas.addEventListener('wheel', onWheel, { passive: false }); - - let wasLocked = false; - const onPointerLockChange = () => { - const locked = document.pointerLockElement === canvas; - this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор - if (locked) { - wasLocked = true; - this._rmbHeld = true; // если попали в lock — ПКМ удерживается - } else if (wasLocked && this._active) { - // pointer-lock снят. Причин три: - // 1) пользователь в UI-режиме (game.input.setCursorMode('ui')) - // 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); - - const isTypingTarget = (target) => { - if (!target) return false; - const tag = (target.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; - return !!target.isContentEditable; - }; - const onKeyDown = (e) => { - if (!this._active) return; - if (isTypingTarget(e.target)) return; - // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest - // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc - // в third (без pointer-lock) сразу выходил из Play. - if (e.code === 'Escape') { - if (this._onExitRequest) { - this._onExitRequest(); - return; - } - } - // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.), - // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик), - // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах). - if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') { - // Глотаем preventDefault только для игровых клавиш - if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); - return; - } - this._codes.add(e.code); - if (e.shiftKey) this._shift = true; - // C — переключение first/third. Отключаем в GD-режиме (автобег > 0) - // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview. - if (e.code === 'KeyC') { - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - if (!inGdMode) this._toggleCameraMode(); - } - // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег, - // поэтому переназначено на L). Курсор центрируется, корпус всегда - // лицом к камере, камера через плечо. - if (e.code === 'KeyL') { - this.setShiftLock(!this._shiftLock); - } - // B — встроенный магазин скинов (задача 07). Открывается только если - // включён в проекте (scene.skins.shopVisible). Toggle. - if (e.code === 'KeyB' && !this._inputBlocked) { - try { this._scene3d?.toggleSkinShop?.(); } catch (err) {} - } - // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) - if (e.code === 'Tab') { - e.preventDefault(); - this.setUiCursorMode(!this._uiCursorMode); - } - if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { - e.preventDefault(); - } - // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус), - // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек. - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) { - e.preventDefault(); - } - }; - const onKeyUp = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.delete(e.code); - if (!e.shiftKey) this._shift = false; - }; - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - - const onBlur = () => { - this._codes.clear(); - this._shift = false; - }; - window.addEventListener('blur', onBlur); - - this._listeners = [ - { target: canvas, type: 'click', fn: onCanvasClick }, - { target: canvas, type: 'wheel', fn: onWheel, - opts: { passive: false } }, - { target: document, type: 'mousemove', fn: onMouseMove }, - { target: document, type: 'pointerlockchange', fn: onPointerLockChange }, - { target: window, type: 'keydown', fn: onKeyDown }, - { target: window, type: 'keyup', fn: onKeyUp }, - { target: window, type: 'blur', fn: onBlur }, - ]; - } - - _tick() { - // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая - // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж - // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1 - // (max 10 кадров/сек физики). Этого хватает чтобы движение было - // плавным даже на 5 FPS — просто чуть рывками. - let dt = this.scene.getEngine().getDeltaTime() / 1000; - if (dt <= 0) return; - if (dt > 0.1) dt = 0.1; - - // === Присед: по Ctrl на десктопе, или через мобильную кнопку - // (которая шлёт keydown 'ControlLeft'). C — НЕ используется - // (это смена вида в Babylon). - // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику - // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship). - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - const wantCrouch = !inGdMode && this._codes - && (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); - if (wantCrouch && !this._crouching) { - this._crouching = true; - // сдвигаем центр капсулы вниз — низ ног остаётся на земле - const dH = this.HALF_H_CROUCH - this.HALF_H; - this.HALF_H = this.HALF_H_CROUCH; - if (this._pos) this._pos.y += dH; - } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { - this._crouching = false; - const dH = this.HALF_H_NORMAL - this.HALF_H; - this.HALF_H = this.HALF_H_NORMAL; - if (this._pos) this._pos.y += dH; - } - - // === Горизонтальное движение === - const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); - const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); - const isSprinting = this._shift; - const speedMult = isSprinting ? this.SPRINT_MULT : 1; - const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; - - let moveX = 0, moveZ = 0; - // c (codes) используется ниже для прыжка/Space — объявляем здесь, - // чтобы был доступен после if/else движения. - const c = this._codes; - const am = this._analogMove; - // === Кубикон Dash: авто-движение по +X, ввод заблокирован === - // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме - // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются. - if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) { - moveX = this._autoRunSpeed * dt; - moveZ = 0; - } else - // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z === - // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит - // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО — - // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал - // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос - // делает скрипт через player.teleport по X. - if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) { - moveX = 0; - moveZ = this._autoRunSpeed * dt; - } else - // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами: - // позволяет двигаться в любом направлении плавно, не только в 8 секторов. - if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) { - // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*). - // *На практике мы передаём sprint через setVirtualShift. - const mag = Math.min(1, Math.hypot(am.x, am.y)); - // Нормализуем направление, скорость = mag * speed - const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y)); - const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y)); - const v = mag * speed; - // y=1 → вперёд (forward), x=1 → вправо (right) - moveX = forward.x * dirY * v + right.x * dirX * v; - moveZ = forward.z * dirY * v + right.z * dirX * v; - } else { - if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; } - if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; } - if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; } - if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; } - } - - const isMoving = (moveX !== 0 || moveZ !== 0); - - // === ЛЁД: инерция движения === - // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость - // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр) - // с коэффициентом ускорения, и затухает с (1 - friction*dt*8). - if (this._iceFriction > 0.001) { - const fric = Math.min(1, this._iceFriction); - // Чем выше friction, тем медленнее набираем скорость и тем дольше - // затухает после отпускания клавиш. - const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45 - const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1 - // Целевая скорость = moveX/moveZ (уже содержит dt) - // Прибавляем разницу с ускорением - this._iceVelX += (moveX - this._iceVelX) * accel; - this._iceVelZ += (moveZ - this._iceVelZ) * accel; - if (!isMoving) { - this._iceVelX *= decay; - this._iceVelZ *= decay; - if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0; - if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0; - } - moveX = this._iceVelX; - moveZ = this._iceVelZ; - } else { - // Сбрасываем накопленную скорость когда лёд выкл - this._iceVelX = 0; - this._iceVelZ = 0; - } - - // === Плавание (если AABB пересекает блок-воду) === - const inWater = this._isInWater(); - const submerged = this._isSubmerged(); - if (inWater) { - // В воде движемся в 2 раза медленнее - moveX *= 0.5; - moveZ *= 0.5; - } - - // === Вертикальное === - if (inWater) { - // Плавание: лёгкая гравитация + плавучесть к поверхности - const buoyancy = submerged ? 6 : 0; - const swimGravity = -3; - this._vy += (buoyancy + swimGravity) * dt; - this._vy *= Math.max(0, 1 - 3 * dt); - if (this._codes.has('Space')) this._vy += 14 * dt; - if (this._vy > 4) this._vy = 4; - if (this._vy < -4) this._vy = -4; - } else { - // Кубикон Dash: умеренная усиленная гравитация для коротких - // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с - // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2, - // не слишком улетает по X (~4-5м за прыжок). - // Variable-jump (отпускание Space обрезает vy) НЕ используется — - // в авто-беге игрок не контролирует длительность нажатия. - // - // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ - // к потолку. Кап-скорости тоже инвертируется. - const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0; - const userGravityMul = this._gravityMul || 1; - const gDir = this._gravityDir || 1; - - if (this._waveMode) { - // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|). - // Гравитация полностью игнорируется — линейное движение по диагонали. - const speed = Math.max(1, this._autoRunSpeed || 8); - this._vy = this._jumpHeld ? speed : -speed; - } else { - this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt; - - // SHIP-режим: при удержании Space даём импульс ВВЕРХ - // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс - // делает плавный полёт. - if (this._shipMode && this._jumpHeld) { - // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался, - // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36) - const SHIP_THRUST = 80; // м/с² против гравитации - this._vy += SHIP_THRUST * gDir * dt; - } - // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем - // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается. - if (this._robotMode && this._robotBoostLeft > 0) { - if (c.has('Space')) { - // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно. - // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену). - this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt; - this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt); - } else { - this._robotBoostLeft = 0; - } - } - - // Cap: ±50 в любом направлении (для Ship — мягче, ±25) - const vyCap = this._shipMode ? 25 : 50; - if (this._vy < -vyCap) this._vy = -vyCap; - if (this._vy > vyCap) this._vy = vyCap; - } - } - - const beforeX = this._pos.x, beforeZ = this._pos.z; - - // === STICK к движущейся платформе === - // Если в прошлом кадре стояли на примитиве/модели, и она двигается — - // двигаем игрока вместе с ней (по дельте позиции платформы за кадр). - if (this._lastGroundData && this._lastGroundPos) { - const gd = this._lastGroundData; - // Текущая позиция платформы — берём из live-data (она обновляется - // movingPlatforms-скриптом через scene.move). - const curX = gd.x, curY = gd.y, curZ = gd.z; - const dPlatX = curX - this._lastGroundPos.x; - const dPlatY = curY - this._lastGroundPos.y; - const dPlatZ = curZ - this._lastGroundPos.z; - // Применяем дельту только если она разумная (защита от телепорта - // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.) - if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) { - this._pos.x += dPlatX; - this._pos.y += dPlatY; - this._pos.z += dPlatZ; - } - } - - // PERF-METRICS: замер физики игрока - const _pt0 = performance.now(); - const result = this.physics.moveAABB( - this._pos, this.HALF_W, this.HALF_H, this.HALF_D, - moveX, this._vy * dt, moveZ - ); - const _bs = this._scene3d || this.scene3d; - if (_bs && _bs._perfMetrics) { - _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; - _bs._perfMetrics.physics_count++; - } - this._pos.set(result.x, result.y, result.z); - if (result.hitY) this._vy = 0; - // Surface-follow на smooth-terrain прижал нас к склону — гравитация - // больше не нужна на этом кадре, иначе будет вибрация от соревнования - // (гравитация тянет вниз → следующий кадр surface поднимает обратно). - if (result.surfaceFollowed) this._vy = 0; - - // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы - // визуально плавно подняться. Физика уже сделала телепорт вверх, - // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже - // в кадровой логике (раз за tick). - if (result.steppedUpBy && result.steppedUpBy > 0) { - this._stepUpVisualOffset += result.steppedUpBy; - // Кэп — не более 1.2м, иначе при множественных степах подряд - // оффсет может стать гигантским и аватар «уйдёт под землю». - if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2; - } - // Спадание оффсета. dt — реальное время тика (секунды). - if (this._stepUpVisualOffset > 0) { - this._stepUpVisualOffset -= this._stepUpDecay * dt; - if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0; - } - - // Запоминаем «на чём стоим» для следующего кадра - if (result.onGround && result.groundData?.data) { - this._lastGroundData = result.groundData.data; - this._lastGroundPos = { - x: result.groundData.data.x, - y: result.groundData.data.y, - z: result.groundData.data.z, - }; - } else { - this._lastGroundData = null; - this._lastGroundPos = null; - } - - // === Авто-вылезание на берег из воды === - // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться — - // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание». - if (inWater && isMoving && (result.hitX || result.hitZ)) { - this._vy = Math.max(this._vy, 5); - } - - // Респавн если игрок упал в пустоту (за пределы baseplate) - if (this._pos.y < -30) { - const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; - this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z); - this._vy = 0; - } - - // === Push unanchored объектов при пересечении === - // Скорость игрока в этом кадре (для направления толчка). - // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0, - // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD) - // и forward камеры — DynamicsManager выберет лучшее направление. - const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt); - const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt); - const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ); - const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal); - // Берём "желаемое" движение если оно больше реального (игрок упёрся, - // но WASD нажаты — значит "пытается толкать"). - const useDesired = desiredSpeed > realSpeed + 0.5; - const pushVx = useDesired ? moveX / dt : playerVxReal; - const pushVz = useDesired ? moveZ / dt : playerVzReal; - if (this._scene3d?.dynamics?.isEnabled?.()) { - this._scene3d.dynamics.applyPushFromPlayer( - this._pos.x, this._pos.y, this._pos.z, - this.HALF_W, this.HALF_H, this.HALF_D, - pushVx, pushVz, - forward.x, forward.z, - playerVxReal, playerVzReal - ); - } - - // === Чекпоинты — обновить точку спавна при касании === - if (this.physics?.getOverlappingPrimitives) { - const overlaps = this.physics.getOverlappingPrimitives( - this._pos.x, this._pos.y, this._pos.z, - this.HALF_W, this.HALF_H, this.HALF_D - ); - for (const data of overlaps) { - if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) { - this._activatedCheckpoints.add(data.id); - if (this._scene3d) { - // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта - // чтобы при респавне игрок не попал внутрь чекпоинта. - this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z); - } - this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь») - } - } - } - - // Шаги — копим пройденную горизонтальную дистанцию когда onGround - if (result.onGround && isMoving) { - const dxReal = this._pos.x - beforeX; - const dzReal = this._pos.z - beforeZ; - this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal); - const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK; - if (this._distanceSinceLastStep >= stepThreshold) { - this._distanceSinceLastStep = 0; - this._playFootstep(); - } - } else { - // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки - // не воспроизвёлся слишком рано. - this._distanceSinceLastStep = 0; - } - - // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть. - // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже - // края, понимает что упал в яму, бесит. С коянот-окном — прощает. - // - // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом», - // используем result.onCeiling вместо onGround. - const gDir = this._gravityDir || 1; - const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false); - if (effGround) { - this._coyoteLeft = 0.12; - } else if (this._coyoteLeft > 0) { - this._coyoteLeft -= dt; - } - const canJump = effGround || this._coyoteLeft > 0; - // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде. - // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации). - // В Ship-режиме обычный jump-импульс отключён — корабль управляется - // только удержанием Space (см. блок _shipMode выше). _jumpHeld - // обновляем чтобы остальная логика (release, double-jump) работала. - if (this._waveMode) { - // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block). - // _jumpHeld для синхронизации с состоянием Space. - this._jumpHeld = c.has('Space'); - } else - if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { - if (!this._jumpHeld) { - // Robot — стартовый импульс полный (как куб) для тапа достаточный, - // boost-фаза 0.45с удлиняет подъём при удержании Space. - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; - this._playJumpSound(); - this._jumpHeld = true; - this._coyoteLeft = 0; - // Robot: запускаем boost-фазу на 0.45с - if (this._robotMode) { - this._robotBoostLeft = 0.45; - } - } - } else if (this._shipMode && c.has('Space')) { - this._jumpHeld = true; - } else if (this._ufoMode && c.has('Space') && !inWater) { - // UFO: каждый отдельный тап = микропрыжок (даже в воздухе). - // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата. - if (!this._jumpHeld) { - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85; - this._playJumpSound(); - this._jumpHeld = true; - } - } - // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка - if (effGround) this._doubleJumpUsed = false; - // Двойной прыжок: в воздухе и Space нажат отдельно (после release). - if (!effGround && !inWater && c.has('Space') - && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) { - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; - this._playJumpSound(); - this._doubleJumpUsed = true; - this._jumpHeld = true; - } - if (!c.has('Space')) this._jumpHeld = false; - - // Звук всплеска при входе/выходе из воды - if (this._wasInWater !== inWater) { - this._playSplashSound(); - this._wasInWater = inWater; - } - - // Состояние игрока для game.player.state ('ground'|'air'|'water'). - this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air'); - // Хук game.onPlayerLand — был в воздухе, коснулся земли. - if (effGround && this._wasOnGround === false && !inWater) { - if (typeof this._onLand === 'function') { - try { this._onLand(); } catch (e) { /* ignore */ } - } - } - this._wasOnGround = effGround; - - // === Камера === - this.camera.position = this._computeCameraPos(); - // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную. - if (this._cameraOverride) { - this._applyCameraOverride(dt); - } - // Camera shake — небольшое случайное смещение по X/Y, затухает. - if (this._cameraShakeLeft > 0) { - this._cameraShakeLeft -= dt; - const t = Math.max(0, this._cameraShakeLeft); - const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка - this.camera.position.x += (Math.random() - 0.5) * amp; - this.camera.position.y += (Math.random() - 0.5) * amp; - } - // Скрипт-управление камерой (cutscene/focus) уже выставило И - // позицию, И направление взгляда через setTarget. НЕ трогаем - // camera.rotation — иначе setTarget затирается и катсцена - // «смотрит прямо» вместо вращения по точкам lookAt. - if (!this._cameraOverride) { - if (this._cameraMode === 'front') { - // Камера смотрит назад на игрока — yaw на 180°, pitch инверт. - this.camera.rotation.x = -this._pitch; - this.camera.rotation.y = this._yaw + Math.PI; - } else if (this._cameraMode === 'sideview') { - // Sideview: камера в -Z от игрока, смотрит на +Z. - this.camera.rotation.x = 0; - this.camera.rotation.y = 0; - this.camera.rotation.z = 0; - } else { - this.camera.rotation.x = this._pitch; - this.camera.rotation.y = this._yaw; - } - if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0; - } - - // === Модель игрока === - if (this._modelRoot) { - // === Поза: в воде персонаж лежит горизонтально (плавает) === - // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция. - const targetSwimTilt = inWater ? Math.PI / 2 : 0; - const swimTilt = this._swimTilt ?? 0; - const tiltStep = 4 * dt; // 4 рад/с скорость наклона - let nextTilt = swimTilt; - if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) { - nextTilt = targetSwimTilt; - } else { - nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep; - } - this._swimTilt = nextTilt; - - // В воде наклон 90° кладёт модель горизонтально. yLift поднимает - // root к центру AABB. Также при наклоне корень оказывается в позиции - // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root - // на длину модели вперёд по yaw чтобы голова была впереди. - const tiltFrac = nextTilt / (Math.PI / 2); // 0..1 - const yLift = inWater ? this.HALF_H * tiltFrac : 0; - const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела - const fwdShift = inWater ? bodyLen * tiltFrac : 0; - const fx = Math.sin(this._modelYaw); - const fz = Math.cos(this._modelYaw); - this._modelRoot.position.set( - this._pos.x + fx * fwdShift, - this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset, - this._pos.z + fz * fwdShift - ); - - // Поворот модели: - // - на суше: направление РЕАЛЬНОГО движения (как было). - // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто - // двигает тело вбок без вращения, как на суше при first-person. - if (inWater) { - const targetYaw = this._yaw; - let diff = targetYaw - this._modelYaw; - while (diff > Math.PI) diff -= Math.PI * 2; - while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt * 2; - if (Math.abs(diff) <= maxStep) { - this._modelYaw = targetYaw; - } else { - this._modelYaw += Math.sign(diff) * maxStep; - } - } else { - const dxReal = this._pos.x - beforeX; - const dzReal = this._pos.z - beforeZ; - const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; - if (movedHorizontal) { - const targetYaw = Math.atan2(dxReal, dzReal); - let diff = targetYaw - this._modelYaw; - while (diff > Math.PI) diff -= Math.PI * 2; - while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt; - if (Math.abs(diff) <= maxStep) { - this._modelYaw = targetYaw; - } else { - this._modelYaw += Math.sign(diff) * maxStep; - } - } - } - // Применяем yaw + swim-tilt. - // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит - // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift). - this._modelRoot.rotation.y = this._modelYaw; - this._modelRoot.rotation.x = nextTilt; - // В воде также добавляем лёгкое покачивание по Z (как волна тела) - if (inWater) { - const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15; - this._modelRoot.rotation.z = wobble; - } else if (this._cameraMode === 'sideview') { - // Кубикон Dash: в воздухе куб крутится назад (по часовой если - // смотреть с -Z), на земле плавно возвращается в 0. - // Скорость подобрана так чтобы между прыжками куб успевал - // совершить ~1 оборот. - const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с - if (!result.onGround) { - this._dashSpinAngle -= SPIN_SPEED * dt; - } else { - // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0) - const TAU = Math.PI * 2; - const target = Math.round(this._dashSpinAngle / TAU) * TAU; - const diff = target - this._dashSpinAngle; - const snapStep = SPIN_SPEED * 1.5 * dt; - if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target; - else this._dashSpinAngle += Math.sign(diff) * snapStep; - } - this._modelRoot.rotation.z = this._dashSpinAngle; - } else { - this._modelRoot.rotation.z = 0; - } - // Поза с оружием — обновляем флаг каждый кадр (на случай смены) - const hasWeapon = !!this._scene3d?.weapons?._equipped; - this._updateExtendedArm(hasWeapon); - // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций. - // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем - // и пишем свои углы Эйлера. Это ключевой момент: вызывается - // каждый кадр, поэтому override переживает анимацию. - this._applyMeshRotationOverrides(); - } - - // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)). - // Применяем каждый кадр — на случай если меши только что асинхронно - // загрузились, либо _applyCameraMode перезаписал enabled=true. - if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) { - for (let i = 0; i < this._modelMeshes.length; i++) { - const m = this._modelMeshes[i]; - if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { - try { m.setEnabled(false); } catch (e) {} - } - } - } - - // Тик распадающихся кусков игрока (после смерти) - this._tickDebris(dt); - - // === Анимации === - // Снимок скорости/опоры для процедурной анимации non-humanoid скинов. - this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1); - this._isGrounded = !!result.onGround; - - // Non-humanoid single-mesh скин: костей нет — анимируем процедурно - // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них. - if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) { - this._animateNonHumanoidMesh(dt); - return; - } - - // R15-скин: процедурный аниматор (нет glTF AnimationGroups). - // Состояния: idle/walk/run/jump/fall. sprint → run. - if (this._isR15 && this._r15Animator) { - let r15State; - if (!result.onGround) { - // vy > 0 — вверх (jump-поза с поджатыми ногами), - // vy < 0 — вниз (fall, лёгкий наклон корпуса). - r15State = (this._vy > 0.5) ? 'jump' : 'fall'; - } else if (inWater) { - r15State = isMoving ? 'walk' : 'idle'; - } else if (isMoving) { - r15State = isSprinting ? 'run' : 'walk'; - } else { - r15State = 'idle'; - } - this._r15Animator.setState(r15State); - this._r15Animator.update(dt); - // Override костей поверх анимации (поза руки с оружием/замах). - this._applyR15BoneOverrides(); - return; // R15 не использует AnimationGroups-путь ниже - } - - let nextAnim; - if (inWater) { - // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе - nextAnim = isMoving ? 'walk' : 'idle'; - } else if (!result.onGround) { - nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle'); - } else if (isMoving) { - nextAnim = isSprinting ? 'sprint' : 'walk'; - } else { - nextAnim = 'idle'; - } - // Снимок состояния для лога в _playAnim - this._lastAnimDebug = { - vy: this._vy, - og: !!result.onGround, - sf: !!result.surfaceFollowed, - mv: !!isMoving, - }; - // === Периодический trace состояния (раз в 30 кадров ~0.5с) === - // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация - // не меняется — полезно для разбора "вибрации". - if (!this._animTraceCnt) this._animTraceCnt = 0; - this._animTraceCnt++; - if (this._animTraceCnt >= 30) { - this._animTraceCnt = 0; - const d = this._lastAnimDebug; - console.log(`[AnimTrace] anim=${this._currentAnim} ` - + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`); - } - this._playAnim(nextAnim); - } -} +/** + * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения). + * + * Камера: + * 1st person — камера в позиции глаз игрока, модель невидима. + * 3rd person — камера сзади-сверху, модель видна. + * Переключение: клавиша C циклит first ↔ third. + * Колесо мыши в third-person — меняет дистанцию (zoom). + * + * Модель игрока: + * Грузим GLB через ModelManager (тот же что для editor-моделей). + * Корневой TransformNode хранит position/rotation модели. + * В 3rd person модель видна и крутится в направлении движения. + * Анимации (idle/walk/sprint) переключаются по скорости/спринту. + * + * Управление: + * W/A/S/D / стрелки — движение в горизонтальной плоскости + * Space — прыжок + * Shift — спринт (×1.7) + * C — переключить камеру 1st ↔ 3rd + * Esc — выйти из игры (через pointer-lock release) + */ +import { + Vector3, UniversalCamera, SceneLoader, TransformNode, + MeshBuilder, StandardMaterial, Color3, + Quaternion, Space, Ray, +} from '@babylonjs/core'; +import { getModelType } from './ModelTypes'; +import { R15Skeleton } from './R15Skeleton'; +import { R15Animator } from './R15Animator'; +// Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md +import { AccessoryManager } from './AccessoryManager'; + +// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). +// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. +const CAMERA_MODES = ['third', 'first', 'front']; +// Для режима 'sideview' (Кубикон Dash): +// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z) +// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб +// и ~12м препятствий впереди влезали в кадр на 16:9. +const SIDEVIEW_DIST = 14; +const SIDEVIEW_HEIGHT = 2.5; + +// Строит абсолютный URL для /api-storys. +// Использует VITE_API_BASE если задан (предпочтительно когда плеер и API +// на разных доменах), иначе fallback: +// - пустой base в dev (vite-proxy роутит сам) +// - текущий origin в prod (предполагаем что API на том же домене) +function _storysApiUrl(path) { + const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; + if (env.VITE_API_BASE) { + return env.VITE_API_BASE + '/api-storys' + path; + } + const isDev = (typeof window !== 'undefined' + && (window.location.hostname === 'localhost' + || window.location.hostname === '127.0.0.1')); + const base = isDev ? '' : (typeof window !== 'undefined' ? window.location.origin : ''); + return base + '/api-storys' + path; +} + +export class PlayerController { + constructor(scene, canvas, physics, scene3d = null) { + this.scene = scene; + this.canvas = canvas; + this.physics = physics; + this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint) + this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали + + // AABB + this.HALF_W = 0.3; + this.HALF_H = 0.9; + this.HALF_D = 0.3; + this.EYE_HEIGHT = 0.7; // глаза от центра AABB + + this.WALK_SPEED = 4.5; + this.SPRINT_MULT = 1.7; + this.JUMP_VELOCITY = 8; + this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне) + this._speedMul = 1; // множитель скорости передвижения + this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная) + this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт) + this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе + this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) + this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) + this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) + // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. + // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. + this._autoRunSpeed = 0; + // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе). + // В sideview-камере при прыжке куб эффектно крутится — визитка GD. + this._dashSpinAngle = 0; + // Camera shake: amplitude + remaining time. Применяется в _tick после + // _computeCameraPos. Используется через game.camera.shake(amp, dur). + this._cameraShakeAmp = 0; + this._cameraShakeLeft = 0; + // Управление камерой из скрипта (Фаза 5.7). null = обычная камера + // игрока. Иначе объект режима: + // { mode:'focus', getTarget } — следить за объектом; + // { mode:'cutscene', points, durations, ... } — пролёт по точкам. + this._cameraOverride = null; + // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть. + // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround. + this._coyoteLeft = 0; + this._doubleJumpEnabled = false; + this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте» + // Кубикон Dash: направление гравитации. +1 = нормально (вниз), + // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD). + // Применяется только в sideview-режиме. Влияет на: + // - vy += GRAVITY * gravityDir * dt + // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз) + // - "onGround" определение: hitY + vy*gravityDir < 0 + // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте). + this._gravityDir = 1; + // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка, + // 1 = полностью скользко (инерция держится бесконечно). Реалистичный + // лёд = ~0.85. Настраивается через game.player.setIceFriction(value). + this._iceFriction = 0; + this._iceVelX = 0; + this._iceVelZ = 0; + // Присед — уменьшает высоту AABB. Включается через + // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45. + this._crouching = false; + this.HALF_H_NORMAL = 0.9; + this.HALF_H_CROUCH = 0.45; + this.GRAVITY = -22; + this.MOUSE_SENSITIVITY = 0.0025; + + // 3rd person camera + this.THIRD_DISTANCE_MIN = 2.5; + this.THIRD_DISTANCE_MAX = 12; + this.THIRD_DISTANCE_DEFAULT = 5; + this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока + + this.camera = null; + this._active = false; + this._onExitRequest = null; + + // Состояние игрока + this._pos = new Vector3(0, 5, 0); + this._vy = 0; + this._yaw = 0; + this._pitch = 0; + + // Камера. Дефолт — первое лицо (как в большинстве игр). + this._cameraMode = 'third'; + this._thirdDistance = this.THIRD_DISTANCE_DEFAULT; + // Порог авто-перехода third→first при зуме колесом (Roblox-style). + this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; + // Если true — нельзя выйти из first-person зумом (lockfirst-режим). + this._lockFirstPerson = false; + // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере. + this._shiftLock = false; + // Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора. + this._rmbHeld = false; + this._mouseIconVisible = true; + + // Ввод + this._codes = new Set(); + this._shift = false; + + // Auto-step visual smoothing. Когда PhysicsAABB телепортирует + // игрока вверх на уступ (steppedUpBy), физически он уже наверху, + // но мы интерполируем рендер: показываем модель и камеру со сдвигом + // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0. + // Получается визуально как плавный «полупрыжок» без рывка. + this._stepUpVisualOffset = 0; + // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс. + this._stepUpDecay = 4.5; + + // Модель игрока (грузится в start) + // Дефолт — R15-скин bacon-hair (классический Roblox-вид). + this._modelTypeId = 'skin_bacon-hair'; + this._modelRoot = null; + this._modelMeshes = []; + // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет + // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР + // в _tick (после анимаций), чтобы скрин оставался скрытым даже после + // асинхронной загрузки модели или после _applyCameraMode. + this._skinVisibleScripted = true; + this._animations = {}; + this._currentAnim = null; + // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока). + // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72. + this._modelScale = 0.72; + + // === R15-скин (bacon-hair и др.) === + // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций). + // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из + // characters//body.glb, детектируем скелет, анимируем + // процедурно через R15Animator (см. _loadPlayerModel / _tick). + this._isR15 = false; // флаг: загружен валидный R15-скелет + this._r15Skeleton = null; // R15Skeleton — резолвер костей + this._r15Animator = null; // R15Animator — процедурные анимации + this._skinManifest = null; // кеш skins_manifest.json + this._skinOverrides = {}; // overrides текущего скина + + // === non-humanoid скины (задача 07) === + // Скин без R15-скелета (животное, машина, абстрактная модель). + // Для них центрируем pivot, считаем собственный AABB и анимируем + // процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick). + this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh' + this._modelHipHeight = null; // локальная база модели (опущена на ноги) + this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели + this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций) + this._isGrounded = true; // флаг «на земле» (для анимаций) + + // === Блокировка ввода/камеры для модалов (задача 04) === + this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter) + this._cameraFrozen = false; // замораживает вращение/зум камеры + this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget) + + // === Жизни игрока === + this.maxHp = 100; + this.hp = 100; + this._lastDamageTime = 0; + this._invulnerabilityTime = 0.5; // 500мс i-frames после удара + this._onHpChange = null; + this._onDeath = null; + + // Звук шагов — простой генератор через Web Audio API. + // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE. + this._audioCtx = null; + this._distanceSinceLastStep = 0; + this.STEP_DISTANCE_WALK = 1.6; + this.STEP_DISTANCE_SPRINT = 1.1; + + // Угол поворота модели — следует за направлением движения, а не yaw камеры. + // Когда игрок стоит — сохраняем последний угол. + this._modelYaw = 0; + this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с) + + this._listeners = []; + this._beforeRender = null; + + // === Stick к движущейся платформе === + // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его + // на дельту движения этого объекта (его позиция могла измениться). + this._lastGroundData = null; + this._lastGroundPos = null; + + // === Тач-режим (мобилки/планшеты) === + // Если true — pointer-lock не запрашивается, mouse-listener не активен, + // ввод управляется снаружи через setVirtualKey/addCameraDelta. + this._touchMode = false; + // Фактическая скорость поворота камеры от тача (рад/пиксель). + this.TOUCH_SENSITIVITY = 0.005; + } + + /** + * Включить тач-режим. Вызывать ДО start(), на тач-устройствах. + * В этом режиме: + * - pointer-lock НЕ запрашивается + * - mousemove игнорируется + * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры), + * но дополнительно работают setVirtualKey() / addCameraDelta(). + */ + setTouchMode(enabled) { + this._touchMode = !!enabled; + } + + /** + * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code: + * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space' + * Для шифта — отдельный параметр. + */ + setVirtualKey(code, pressed) { + if (pressed) this._codes.add(code); + else this._codes.delete(code); + } + + /** Программное нажатие/отпускание Shift (бег). */ + setVirtualShift(pressed) { + this._shift = !!pressed; + } + + /** + * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены). + * dx, dy — пиксели свайпа. + */ + addCameraDelta(dx, dy) { + this._yaw += dx * this.TOUCH_SENSITIVITY; + this._pitch += dy * this.TOUCH_SENSITIVITY; + const lim = Math.PI / 2 - 0.05; + if (this._pitch > lim) this._pitch = lim; + if (this._pitch < -lim) this._pitch = -lim; + } + + /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */ + triggerJump() { + this._codes.add('Space'); + // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить + // нажатие и инициировать прыжок (он проверяет onGround + Space). + setTimeout(() => this._codes.delete('Space'), 100); + } + + /** + * Аналоговый ввод движения для тач-джойстика. + * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры), + * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint). + * + * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться + * к дискретным клавишам, передай null. + */ + setAnalogMove(x, y) { + if (x === null || y === null) { + this._analogMove = null; + return; + } + if (!this._analogMove) this._analogMove = { x: 0, y: 0 }; + this._analogMove.x = x; + this._analogMove.y = y; + } + + setOnExitRequest(cb) { + this._onExitRequest = cb; + } + + /** Установить тип модели персонажа — должен быть вызван ДО start(). */ + setModelType(typeId) { + this._modelTypeId = typeId || 'character-a'; + } + + /** + * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07). + * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту, + * грузит новую модель (R15 или non-humanoid). Возвращает Promise. + * + * Используется из game.player.setSkin(slug). + */ + async reloadSkin(typeId) { + if (!this._active) return false; + const newType = typeId || 'character-a'; + if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин + // 1) Выгрузить текущую модель и связанные аниматоры. + try { + if (this._modelRoot) { this._modelRoot.dispose(false, true); } + } catch (e) { /* ignore */ } + this._modelRoot = null; + this._modelMeshes = []; + this._rightArmMeshes = []; + this._r15Skeleton = null; + this._r15Animator = null; + this._isR15 = false; + this._modelKind = 'r15'; + this._modelHipHeight = null; + this._nonHumanoidBox = null; + // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит). + this.HALF_W = 0.3; + this.HALF_H = 0.9; + this.HALF_D = 0.3; + this.HALF_H_NORMAL = 0.9; + this.EYE_HEIGHT = 0.7; + // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу. + this._pos.y += 0.5; + // 4) Загрузить новую модель. + this._modelTypeId = newType; + await this._loadPlayerModel(); + return !!this._modelRoot; + } + + /** + * Запустить режим игры. + * spawnPos — точка спавна. Если не указано — (0, 5, 0). + */ + async start(spawnPos = null) { + if (this._active) return; + this._active = true; + + if (spawnPos) { + this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z); + } else { + this._pos = new Vector3(0, 5 + this.HALF_H, 0); + } + this._vy = 0; + this._yaw = 0; + this._pitch = 0; + this._modelYaw = 0; + this._codes.clear(); + this._shift = false; + + // FPS-камера + const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene); + cam.minZ = 0.1; + cam.maxZ = 1000; + cam.fov = 1.05; + cam.inputs.clear(); + this.scene.activeCamera = cam; + this.camera = cam; + + this._setupInput(); + + // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит + // без меша (или появляется частично), а движение/колайдер уже + // активны. start() теперь async-функция — все её вызовы (`await`). + await this._loadPlayerModel(); + + // Render-loop hook + this._beforeRender = () => this._tick(); + this.scene.registerBeforeRender(this._beforeRender); + + // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock + // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно — + // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ. + 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 */ } + } + + /** + * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых + * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём + * pointerlockchange и пробуем снова один раз. + */ + /** + * Включить/выключить «UI-режим курсора». + * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается. + * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false). + */ + /** Колбэк изменения HP — ({hp, maxHp}). */ + setOnHpChange(cb) { this._onHpChange = cb; } + setOnDeath(cb) { this._onDeath = cb; } + + /** Нанести урон игроку (с учётом i-frames). */ + takeDamage(amount, source) { + if (this.hp <= 0) return; + const now = performance.now() / 1000; + if (now - this._lastDamageTime < this._invulnerabilityTime) return; + this._lastDamageTime = now; + this.hp = Math.max(0, this.hp - Math.max(0, amount)); + // Flash-эффект для UI (через onHpChange флаг damaged=true) + if (this._onHpChange) { + try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {} + } + // Звук «ой» + this._playHurtSound(); + if (this.hp === 0) { + // Эффект распада + this._spawnDeathDebris(); + // Прячем модель игрока + if (this._modelRoot) this._modelRoot.setEnabled(false); + if (this._onDeath) { + try { this._onDeath(); } catch (e) {} + } + } + } + + /** Распад на куски при смерти. */ + _spawnDeathDebris() { + if (!this._pos) return; + const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z; + const colors = [ + new Color3(0.95, 0.78, 0.6), // кожа + new Color3(0.7, 0.5, 0.4), + new Color3(0.4, 0.4, 0.7), // одежда + new Color3(0.3, 0.25, 0.2), + ]; + for (let i = 0; i < 10; i++) { + const size = 0.18 + Math.random() * 0.14; + const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene); + const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene); + mat.diffuseColor = colors[i % colors.length]; + mat.specularColor = new Color3(0, 0, 0); + cube.material = mat; + cube.position.set( + cx + (Math.random() - 0.5) * 0.5, + cy + Math.random() * 0.6, + cz + (Math.random() - 0.5) * 0.5 + ); + cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); + cube.isPickable = false; + cube.alwaysSelectAsActiveMesh = true; + const debris = { + mesh: cube, mat, + vx: (Math.random() - 0.5) * 5, + vy: 4 + Math.random() * 3, + vz: (Math.random() - 0.5) * 5, + rx: (Math.random() - 0.5) * 10, + ry: (Math.random() - 0.5) * 10, + rz: (Math.random() - 0.5) * 10, + age: 0, + life: 2.0, + }; + if (!this._debris) this._debris = []; + this._debris.push(debris); + } + } + + /** Тик debris — вызывается в _tick. */ + _tickDebris(dt) { + if (!this._debris || this._debris.length === 0) return; + const G = -10; + const next = []; + for (const d of this._debris) { + d.age += dt; + if (d.age >= d.life) { + try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} + continue; + } + d.vy += G * dt; + d.mesh.position.x += d.vx * dt; + d.mesh.position.y += d.vy * dt; + d.mesh.position.z += d.vz * dt; + if (d.mesh.position.y < 0.1) { + d.mesh.position.y = 0.1; + d.vy *= -0.4; + d.vx *= 0.6; + d.vz *= 0.6; + } + d.mesh.rotation.x += d.rx * dt; + d.mesh.rotation.y += d.ry * dt; + d.mesh.rotation.z += d.rz * dt; + const fadeStart = d.life - 0.5; + if (d.age > fadeStart) { + const k = 1 - (d.age - fadeStart) / 0.5; + d.mesh.visibility = Math.max(0, k); + } + next.push(d); + } + this._debris = next; + } + + /** Короткий звук «ой» когда получили урон. */ + _playHurtSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(220, t); + osc.frequency.exponentialRampToValueAtTime(80, t + 0.15); + const g = ctx.createGain(); + g.gain.setValueAtTime(0.15, t); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.2); + osc.connect(g).connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.22); + } catch (e) { /* ignore */ } + } + + /** Полное восстановление HP (например при респавне). */ + healFull() { + this.hp = this.maxHp; + this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна + // Возвращаем модель + if (this._modelRoot) this._modelRoot.setEnabled(true); + // Сбрасываем оставшиеся debris + if (this._debris) { + for (const d of this._debris) { + try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} + } + this._debris = []; + } + if (this._onHpChange) { + try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {} + } + } + + setUiCursorMode(enabled) { + this._uiCursorMode = !!enabled; + if (enabled) { + // Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если + // меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit- + // камера после закрытия меню «думает», что ПКМ всё ещё активна. + this._rmbHeld = false; + // Освобождаем мышь + if (document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) { /* ignore */ } + } + } else { + // Возвращаем lock — но только если мы реально активны + if (this._active) { + this._requestPointerLockSafe(); + } + } + } + isUiCursorMode() { return !!this._uiCursorMode; } + + /** + * Callback, который вызывается при движении мыши в UI-режиме. + * fn(x, y) — нормализованные координаты [0..1] относительно канваса. + * Используется для drag-механик (Дальгона и т.д.). + */ + setUiMouseMoveCallback(fn) { + this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null; + } + /** mousedown в UI-режиме. fn(x, y). */ + setUiMouseDownCallback(fn) { + this._uiMouseDownCb = (typeof fn === 'function') ? fn : null; + } + /** mouseup в UI-режиме. fn(x, y). */ + setUiMouseUpCallback(fn) { + this._uiMouseUpCb = (typeof fn === 'function') ? fn : null; + } + + _requestPointerLockSafe(retried = false) { + if (!this._active || !this.canvas?.requestPointerLock) return; + // На тач-устройствах pointer-lock не нужен — управление через touch-overlay + if (this._touchMode) return; + // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui')) + // — не захватываем мышь. + if (this._uiCursorMode) return; + // Если уже есть lock на этот canvas — нечего делать + if (document.pointerLockElement === this.canvas) return; + // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем + if (document.pointerLockElement && document.pointerLockElement !== this.canvas) { + if (retried) return; // только одна попытка повтора + const onChange = () => { + document.removeEventListener('pointerlockchange', onChange); + if (this._active) this._requestPointerLockSafe(true); + }; + document.addEventListener('pointerlockchange', onChange, { once: true }); + return; + } + requestAnimationFrame(() => { + if (!this._active) return; + try { + const p = this.canvas.requestPointerLock(); + // Promise-форма: ловим reject (SecurityError) и пробуем повтор + if (p && typeof p.catch === 'function') { + p.catch((err) => { + if (!this._active) return; + // SecurityError — попробуем ещё раз через кадр (один раз) + if (!retried && err && err.name === 'SecurityError') { + setTimeout(() => this._requestPointerLockSafe(true), 50); + } + }); + } + } catch (e) { /* legacy form, ignore */ } + }); + } + + /** + * Загрузить манифест R15-скинов (характеристики + overrides). + * Кешируется в this._skinManifest. Возвращает массив skins или []. + */ + async _loadSkinManifest() { + if (this._skinManifest) return this._skinManifest; + // ВАЖНО: объединяем ОБА источника, а не «или-или». + // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался + // ТОЛЬКО он, а статичный 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 { + const resp = await fetch(_storysApiUrl('/rublox/avatars')); + if (resp.ok) { + const json = await resp.json(); + const items = json.items || []; + // Нормализуем: file уже полный путь (absolute_file=true), т.к. + // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс. + const avatars = items.map((a) => ({ + id: a.code, + name: a.name, + file: a.file_path, + overrides: a.overrides || {}, + absolute_file: true, + })); + // Аватары имеют приоритет при совпадении id — кладём в начало. + const avatarIds = new Set(avatars.map((a) => a.id)); + combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id))); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] /rublox/avatars failed:', e); + } + this._skinManifest = combined; + return combined; + } + + /** + * Определить путь к GLB и overrides для текущего _modelTypeId. + * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста + * - иначе → старая Kenney-модель через getModelType() + * Возвращает { file, isR15, overrides } или null. + */ + async _resolveModelSource() { + const typeId = this._modelTypeId || 'character-a'; + // eslint-disable-next-line no-console + console.log(`[PlayerController] _resolveModelSource typeId=${typeId}`); + // Подфаза 3.6 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: + // body-skin от дизайнера передаётся как 'body:' или + // прямой URL ('/api-storys/...' или 'http(s)://...'). + // Подтягиваем item из rublox_items.serialize, берём path. + if (typeId.startsWith('body:')) { + const itemId = typeId.slice(5); + const item = await this._fetchBodySkinItem(itemId); + if (item && item.path) { + return { + file: item.path, + isR15: true, + overrides: {}, // overrides пока не поддерживаем для дизайнерских body + }; + } + // fallback на дефолт + return { + file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', + isR15: true, overrides: {}, + }; + } + // 2026-05-27: 'designer_avatar:' — preview-режим для аватара из БД + // (rublox_avatars). Используется в /_preview-avatar/. + if (typeId.startsWith('designer_avatar:')) { + const avId = typeId.slice('designer_avatar:'.length); + const item = await this._fetchDesignerAvatar(avId); + if (item && item.file_path) { + return { + file: item.file_path, + isR15: true, + overrides: item.overrides || {}, + }; + } + // Fallback на бекона — но с глобальным флагом ошибки, чтобы + // preview-route смог показать понятный alert почему. + try { + window.__previewFallbackReason = + this._lastDesignerAvatarError + || `Designer avatar #${avId} не загрузился — fallback на бекона`; + } catch (e) {} + // eslint-disable-next-line no-console + console.warn( + `[PlayerController] designer_avatar:${avId} НЕ загружен. ` + + `Fallback на бекона. Причина: ${this._lastDesignerAvatarError || 'unknown'}` + ); + return { + file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', + isR15: true, overrides: {}, + }; + } + if (typeId.startsWith('/') || typeId.startsWith('http')) { + // Прямой URL (для preview-режима или тестов). + return { file: typeId, isR15: true, overrides: {} }; + } + // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные + // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs. + if (typeId.startsWith('customskin:')) { + const slug = typeId.slice('customskin:'.length); + const list = this._scene3d?._skinsConfig?.customGlbs || []; + const meta = list.find(g => g && g.slug === slug) || null; + const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null; + if (url) { + return { + file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {}, + scaleManifest: meta?.scale ?? 1.5, + hipHeight: meta?.hipHeight ?? 0.4, + rotationYOffset: meta?.rotationYOffset ?? 0, + isDataUrl: true, + }; + } + return null; + } + if (typeId.startsWith('skin_')) { + const manifest = await this._loadSkinManifest(); + const entry = manifest.find((s) => s.id === typeId); + if (entry) { + // kind определяет систему анимации: + // 'r15' → R15-скелет (как раньше) + // 'non-humanoid-mesh' → single-mesh, процедурное покачивание + // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup + // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом). + const kind = entry.kind || 'r15'; + // absolute_file=true (источник /rublox/avatars) — file уже + // полный URL (legacy /kubikon-assets/... или дизайнерский + // /api-storys/...). Без флага — это легаси-формат + // skins_manifest.json без префикса. + const file = entry.absolute_file + ? entry.file + : '/kubikon-assets/' + entry.file; + return { + file, + isR15: kind === 'r15', + kind, + overrides: entry.overrides || {}, + scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null, + hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null, + rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, + }; + } + // нет в манифесте — пробуем прямой путь + return { + file: `/kubikon-assets/characters/${typeId}/body.glb`, + isR15: true, + kind: 'r15', + overrides: {}, + }; + } + const modelType = getModelType(typeId); + if (!modelType) return null; + return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; + } + + /** Подгрузить metadata designer-аватара по id через api-storys. */ + async _fetchDesignerAvatar(avatarId) { + try { + this._designerAvatarCache = this._designerAvatarCache || {}; + if (this._designerAvatarCache[avatarId]) { + return this._designerAvatarCache[avatarId]; + } + const jwt = (localStorage.getItem('player_jwt') + || localStorage.getItem('Authorization') || ''); + const cleanJwt = jwt.startsWith('Bearer ') ? jwt.slice(7) : jwt; + const url = _storysApiUrl(`/designer/avatars/${avatarId}`); + // eslint-disable-next-line no-console + console.log(`[PlayerController] _fetchDesignerAvatar GET ${url}`); + // 10-секундный таймаут — fetch без него может висеть бесконечно + // (CORS-preflight, медленная сеть, упавший прокси). + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 10000); + let resp; + try { + resp = await fetch(url, { + headers: cleanJwt + ? { Authorization: 'Bearer ' + cleanJwt } + : {}, + signal: ctrl.signal, + }); + } finally { clearTimeout(timer); } + if (!resp.ok) { + // Получим тело для диагностики + let detail = ''; + try { + const txt = await resp.text(); + detail = txt.slice(0, 200); + } catch (e) {} + // eslint-disable-next-line no-console + console.warn( + `[PlayerController] _fetchDesignerAvatar id=${avatarId} ` + + `HTTP ${resp.status} ${resp.statusText}; jwt=${cleanJwt ? 'есть' : 'НЕТ'}; ` + + `body: ${detail}`, + ); + // Прокидываем ошибку наверх чтобы preview-route показал alert. + const err = new Error( + `Не удалось получить аватар #${avatarId}: HTTP ${resp.status}. ` + + (resp.status === 401 ? 'JWT не валиден или истёк.' + : resp.status === 403 ? 'Нет роли «дизайнер» в team.' + : resp.status === 404 ? 'Аватар не найден в БД.' + : 'См. консоль для деталей.') + ); + err.status = resp.status; + err.detail = detail; + throw err; + } + const data = await resp.json(); + const item = data.item || data; + this._designerAvatarCache[avatarId] = item; + return item; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _fetchDesignerAvatar failed:', e); + // Сохраняем ошибку чтобы preview-route мог её показать. + this._lastDesignerAvatarError = e?.message || String(e); + return null; + } + } + + /** Подгрузить metadata body-skin (item) по id через api-storys. */ + async _fetchBodySkinItem(itemId) { + try { + // Кэш на инстанс — _modelTypeId не меняется внутри одной сессии. + this._bodySkinCache = this._bodySkinCache || {}; + if (this._bodySkinCache[itemId]) return this._bodySkinCache[itemId]; + const resp = await fetch( + _storysApiUrl(`/designer/skins/${itemId}`), + { + headers: { + Authorization: 'Bearer ' + + (localStorage.getItem('player_jwt') + || localStorage.getItem('Authorization') || ''), + }, + }, + ); + if (!resp.ok) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _fetchBodySkinItem HTTP', resp.status); + return null; + } + const data = await resp.json(); + const item = data.item || data; + this._bodySkinCache[itemId] = item; + return item; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _fetchBodySkinItem failed:', e); + return null; + } + } + + /** Загрузить GLB-модель персонажа и его анимации. */ + async _loadPlayerModel() { + // eslint-disable-next-line no-console + console.log(`[PlayerController] _loadPlayerModel start, modelTypeId=${this._modelTypeId}`); + const source = await this._resolveModelSource(); + // eslint-disable-next-line no-console + console.log(`[PlayerController] _resolveModelSource → ${source ? source.file : 'NULL'}`); + if (!source) return; + if (!this._active) return; + + // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш + // ModelManager. Если бы мы использовали тот же AssetContainer + // что и зомби (через _loadPrototype), повторный + // instantiateModelsToScene давал меши с битыми материалами. + // Babylon HTTP-кэш всё равно убирает сетевые запросы. + // + // ВАЖНО (2026-05-27): file_path для дизайнерских аватаров приходит + // как '/api-storys/...' — на проде player.rublox.pro это даст 404 + // (там нет такого пути). Конвертируем в абсолютный URL minecraftia. + let absFile = source.file; + if (absFile && absFile.startsWith('/api-storys/')) { + const isDev = (typeof window !== 'undefined' + && (window.location.hostname === 'localhost' + || window.location.hostname === '127.0.0.1')); + if (!isDev) { + absFile = 'https://minecraftia-school.ru' + absFile; + } + } + let rootUrl, filename; + if (source.isDataUrl) { + // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' + // и filename=data:... с подсказкой расширения через 5-й аргумент. + rootUrl = ''; + filename = absFile; + } else { + const lastSlash = absFile.lastIndexOf('/'); + rootUrl = absFile.substring(0, lastSlash + 1); + filename = absFile.substring(lastSlash + 1); + } + // eslint-disable-next-line no-console + console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`); + // Прогресс-индикатор для больших GLB (некоторые дизайнерские + // аватары до 60 МБ — на медленной сети идут минутами, без прогресса + // выглядит как зависание). Публикуем в глобал чтобы PreviewRoute + // мог его показать. + try { window.__playerLoadProgress = { loaded: 0, total: 0 }; } catch (e) {} + const onProgress = (evt) => { + try { + if (evt && evt.lengthComputable) { + window.__playerLoadProgress = { + loaded: evt.loaded, total: evt.total, + }; + const pct = ((evt.loaded / evt.total) * 100).toFixed(0); + // eslint-disable-next-line no-console + console.log(`[PlayerController] GLB загрузка: ${pct}% ` + + `(${(evt.loaded / 1024 / 1024).toFixed(1)}/` + + `${(evt.total / 1024 / 1024).toFixed(1)} МБ)`); + } + } catch (e) {} + }; + let container; + try { + container = await SceneLoader.LoadAssetContainerAsync( + rootUrl, filename, this.scene, onProgress, + source.isDataUrl ? '.glb' : undefined, + ); + try { window.__playerLoadProgress = null; } catch (e) {} + } catch (e) { + try { window.__playerLoadProgress = null; } catch (e2) {} + // eslint-disable-next-line no-console + console.error('[PlayerController] failed to load model:', e); + return; + } + try { + if (!this._active) { + try { container.dispose(); } catch (e) {} + return; + } + // Создаём корневой узел и инстанцируем модель туда + const root = new TransformNode('playerModel', this.scene); + // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока). + // - R15-скины ('skin_*'): фиксированный 0.301 — модели + // нормализованы к 5.98 ед пайплайном auto_rig_bacon + // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах + // с торчащими волосами/плащами (как у bacon-hair). + // - Kenney-модели: старый 0.72. + // - overrides.scale_mult — per-skin множитель из манифеста. + // Non-humanoid скины (животное/машина/еда) масштабируются иначе: + // базовый размер из манифеста (scale), без фикс-0.301. + const isNonHumanoid = source.kind === 'non-humanoid-mesh' + || source.kind === 'non-humanoid-rigged'; + let modelScale; + if (isNonHumanoid) { + modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0; + } else { + modelScale = source.isR15 ? 0.301 : this._modelScale; + const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; + modelScale *= scaleMult; + } + root.scaling = new Vector3(modelScale, modelScale, modelScale); + if (source.rotationYOffset) root.rotation.y = source.rotationYOffset; + const inst = container.instantiateModelsToScene( + (name) => `player_${name}`, + /*cloneAnimations*/ true, + { doNotInstantiate: false } + ); + for (const r of inst.rootNodes) { + r.parent = root; + // Фолбэк для GLB с face_dir_invert (старый Blender→glTF + // конвейер экспортировал лицом в -Z). После 2026-05-27 + // запеченные через bake_avatars_rotate.sh аватары флаг не + // требуют, но механизм оставлен для будущих кривых загрузок. + if (source.overrides && source.overrides.face_dir_invert) { + r.rotation.y = (r.rotation.y || 0) + Math.PI; + } + } + this._modelRoot = root; + this._modelKind = source.kind || 'r15'; + // hipHeight: на сколько центр модели поднят от «низа ног». + this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null; + + // Non-humanoid: нормализуем размер и опускаем модель на «ноги». + if (isNonHumanoid) { + this._setupNonHumanoidModel(root, modelScale, source); + } + + // === R15-скин: детекция скелета === + // R15-скины приходят с встроенным скелетом Mixamo. Babylon + // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер + // и, если скелет валидный, помечаем _isR15 + создаём аниматор. + this._isR15 = false; + this._r15Skeleton = null; + this._r15Animator = null; + this._skinOverrides = source.overrides || {}; + // eslint-disable-next-line no-console + console.log('[PlayerController] _loadPlayerModel: file=' + source.file + + ' isR15=' + source.isR15 + + ' inst.skeletons=' + ((inst.skeletons || []).length) + + ' rootNodes=' + (inst.rootNodes || []).length); + if (source.isR15) { + // Скелет ищем в нескольких местах: inst.skeletons (норма), + // container.skeletons (иногда не клонируется), на мешах + // модели (skeleton-property). Берём первый найденный. + let sk = (inst.skeletons && inst.skeletons[0]) || null; + if (!sk && container.skeletons && container.skeletons.length > 0) { + sk = container.skeletons[0]; + } + if (!sk) { + const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton); + if (meshWithSkel) sk = meshWithSkel.skeleton; + } + if (sk) { + // eslint-disable-next-line no-console + console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length + + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(',')); + const r15 = new R15Skeleton(sk); + if (r15.isValidR15()) { + this._r15Skeleton = r15; + this._isR15 = true; + this._r15Animator = new R15Animator(r15, this._skinOverrides); + // eslint-disable-next-line no-console + console.log('[PlayerController] R15-скин загружен:', + this._modelTypeId, '— костей:', r15.resolvedNames().length, + 'overrides:', JSON.stringify(this._skinOverrides)); + } else { + // eslint-disable-next-line no-console + console.warn('[PlayerController] R15-скин', this._modelTypeId, + '— скелет не прошёл валидацию. Зарезолвлено:', + r15.resolvedNames().join(','), + '| все кости скелета:', + (sk.bones || []).map(b => b.name).join(',')); + } + } else { + // eslint-disable-next-line no-console + console.warn('[PlayerController] R15-скин', this._modelTypeId, + '— нет скелета в glb'); + } + } + + // Собираем все mesh-чилдрены (для toggle visibility в 1st person) + this._modelMeshes = root.getChildMeshes(false); + // Игрок не должен ловить свой raycast → отключаем pickable + for (const m of this._modelMeshes) { + m.isPickable = false; + if (m.alwaysSelectAsActiveMesh !== undefined) { + m.alwaysSelectAsActiveMesh = true; + } + // Тени: персонаж принимает тени от мира (тень дерева + // ложится на тело игрока) и сам отбрасывает тень — caster + // регистрируется отдельно через _scene3d.addShadowCaster. + m.receiveShadows = true; + } + // Игрок ОТБРАСЫВАЕТ тень — регистрируем mesh-части как shadow casters. + try { + if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') { + for (const m of this._modelMeshes) { + this._scene3d.addShadowCaster(m); + } + } + } catch (e) { /* ignore */ } + // У Kenney character имена `arm-left`/`arm-right` соответствуют + // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right). + // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его + // правая рука у нас слева на экране. + // + // Берём именно arm-right (его правую) — это та рука куда логично + // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4. + this._rightArmMeshes = []; + for (const m of this._modelMeshes) { + const n = (m.name || '').toLowerCase(); + // Берём именно «right» — настоящая правая рука персонажа + if (n === 'player_arm-right' || n.endsWith('arm-right') + || n.includes('right-arm') || n.includes('rightarm') + || n.includes('right-hand') || n.includes('hand-right')) { + this._rightArmMeshes.push(m); + break; + } + } + // Fallback: если по имени не нашли — берём левую по позиции (x<0) + if (this._rightArmMeshes.length === 0) { + let bestMesh = null; + let bestX = Infinity; + for (const m of this._modelMeshes) { + if (!m.position) continue; + const n = (m.name || '').toLowerCase(); + if (n.includes('leg') || n.includes('foot') || n.includes('head') + || n.includes('hat') || n.includes('torso') || n.includes('root')) continue; + const px = m.position.x; + const py = m.position.y; + if (py < 0.8 || py > 2.2) continue; + if (px >= -0.05) continue; // ищем X<0 + if (px < bestX) { bestX = px; bestMesh = m; } + } + if (bestMesh) this._rightArmMeshes = [bestMesh]; + } + const arm = this._rightArmMeshes[0]; + if (arm) { + this._rightArmX = arm.position.x; + this._rightArmY = arm.position.y; + this._rightArmZ = arm.position.z; + } + + // Анимации. + // R15-скины не содержат AnimationGroups (анимируются процедурно + // через R15Animator в _tick). Kenney-модели — наоборот, имеют + // встроенные AnimationGroups (idle/walk/sprint/jump). + this._animations = {}; + if (!this._isR15) { + const groups = inst.animationGroups || []; + for (const g of groups) { + const name = (g.name || '').toLowerCase(); + if (name.includes('idle')) this._animations.idle = g; + else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g; + else if (name.includes('walk')) this._animations.walk = g; + else if (name.includes('jump')) this._animations.jump = g; + g.stop(); + } + this._playAnim('idle'); + } + // Применяем текущий camera-mode (показать/скрыть модель) + this._applyCameraMode(); + + // Подфаза 3.3 — создаём AccessoryManager после успешной загрузки + // тела. Если был старый (после смены скина) — его аксессуары + // уже задиспозены вместе со старым modelRoot, просто + // создаём заново. + this._accessoryManager = new AccessoryManager( + this.scene, this._r15Skeleton, this._modelRoot, + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[PlayerController] failed to load model:', e); + } + } + + /** + * Настройка non-humanoid модели (животное/машина/еда): нормализация + * размера и опускание на «низ ног». В отличие от R15 (нормализованы + * пайплайном), эти модели произвольного размера, поэтому считаем bbox. + * + * Локальные координаты root: модель должна стоять так, чтобы её низ был + * на y=0 (там «ноги»). PlayerController позиционирует root в точке + * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю. + */ + _setupNonHumanoidModel(root, scaleApplied, source) { + try { + // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ + // применения scaling root'а. Babylon refreshBoundingInfo нужен после + // инстансинга. + const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0); + if (!meshes.length) return; + root.computeWorldMatrix(true); + let minY = Infinity, maxY = -Infinity, maxDim = 0; + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const m of meshes) { + m.computeWorldMatrix(true); + // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного + // скелета/морфов; без него minimumWorld у инстансов часто нулевой + // или из исходной позы → центр считался неверно (баг пришельца/робота). + try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} } + const bi = m.getBoundingInfo(); + const bb = bi.boundingBox; + const lo = bb.minimumWorld, hi = bb.maximumWorld; + if (!lo || !hi) continue; + minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y); + minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x); + minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z); + } + if (!Number.isFinite(minX) || !Number.isFinite(minY)) return; + const h = maxY - minY; + const w = maxX - minX; + const d = maxZ - minZ; + maxDim = Math.max(h, w, d); + // === Центрирование модели через pivot-node === + // Многие Kenney-модели имеют origin НЕ в геометрическом центре + // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого + // origin (баг пришельца/робота). Ручной сдвиг детей с делением на + // scaleApplied неверен если у детей свой scale/rotation. Надёжно: + // вставляем промежуточный pivot между root и моделью и смещаем pivot + // на -localCenter (через инверсию world-матрицы root — точно при + // любом scale/rotation). + const worldCenter = new Vector3( + (minX + maxX) / 2, // центр X + minY, // низ Y (модель «садится» на ноги) + (minZ + maxZ) / 2 // центр Z + ); + // world-центр → локальные координаты root + const invRoot = root.getWorldMatrix().clone().invert(); + const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot); + const pivot = new TransformNode('playerModelPivot', this.scene); + pivot.parent = root; + pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z); + // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot. + for (const ch of root.getChildren().slice()) { + if (ch === pivot) continue; + ch.parent = pivot; + } + // Сохраняем размеры для настраиваемого AABB и камеры. + // hipHeight из манифеста — приоритетно; иначе берём низ модели. + this._nonHumanoidBox = { w, h, d }; + this._modelBaseHeight = h; + // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды). + // Ограничиваем разумными пределами чтобы не проваливаться/застревать. + this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2)); + this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2)); + const halfH = Math.max(0.3, Math.min(1.0, h / 2)); + this.HALF_H = halfH; + this.HALF_H_NORMAL = halfH; + this.EYE_HEIGHT = halfH * 0.7; + // eslint-disable-next-line no-console + console.log('[PlayerController] non-humanoid setup:', this._modelTypeId, + 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2), + 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _setupNonHumanoidModel failed:', e); + } + } + + /** + * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать + * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при + * беге + наклон в воздухе. Вызывается каждый кадр из _tick. + * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel). + */ + _animateNonHumanoidMesh(dt) { + const root = this._modelRoot; + if (!root) return; + const t = (typeof performance !== 'undefined' && performance.now) + ? performance.now() / 1000 : Date.now() / 1000; + const speed = this._lastFrameSpeed || 0; + // Базовое вращение по yaw уже выставляет _tick (он крутит модель под + // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt + // поверх — храним их в отдельных полях, чтобы _tick их не перетёр. + let bobY = 0, tiltX = 0; + if (!this._isGrounded) { + tiltX = 0.2; // в воздухе — нос вверх + } else if (speed > 0.1) { + const bobFreq = 8 * Math.min(2, speed / 4); + bobY = Math.sin(t * bobFreq) * 0.06; + tiltX = Math.min(speed * 0.04, 0.13); + } else { + bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое + } + // Применяем поверх позиции, которую _tick уже выставил в root.position.y. + root.position.y += bobY; + // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом. + root.rotation.x = tiltX; + } + + // ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ── + + /** + * Надеть аксессуар. item — RubloxItem.serialize с полем attachment. + * Возвращает Promise (handle.dispose() снимает). + * Если в этом слоте уже надето что-то — старое снимается автоматом. + */ + async equipAccessory(item) { + if (!this._accessoryManager) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] equipAccessory: model not loaded yet'); + return null; + } + return this._accessoryManager.attach(item); + } + + /** Снять аксессуар из слота (hat/tool/hair/face/...). */ + unequipSlot(slot) { + if (this._accessoryManager) this._accessoryManager.detachSlot(slot); + } + + /** Снять все аксессуары. */ + unequipAll() { + if (this._accessoryManager) this._accessoryManager.detachAll(); + } + + /** Геттер для прямого доступа (used by calibration UI / DevTools). */ + getAccessoryManager() { + return this._accessoryManager || null; + } + + /** AABB игрока пересекает хотя бы один блок-воду. */ + _isInWater() { + const bm = this._scene3d?.blockManager; + if (!bm) return false; + // FAST PATH: если на сцене нет водных блоков — точно не в воде. + // Большинство карт (зомби-остров, любые «суховые») — без воды, + // и тройной цикл ниже бесполезно тратит время каждый кадр. + if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; + const px = this._pos.x, py = this._pos.y, pz = this._pos.z; + const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D; + // Проверяем клетки которые AABB перекрывает + const gxMin = Math.floor(px - hw + 0.5); + const gxMax = Math.floor(px + hw + 0.5); + const gzMin = Math.floor(pz - hd + 0.5); + const gzMax = Math.floor(pz + hd + 0.5); + const gyMin = Math.floor(py - hh); + const gyMax = Math.floor(py + hh); + for (let gx = gxMin; gx <= gxMax; gx++) { + for (let gy = gyMin; gy <= gyMax; gy++) { + for (let gz = gzMin; gz <= gzMax; gz++) { + const m = bm.blocks.get(`${gx},${gy},${gz}`); + if (m?.metadata?.isWater) return true; + } + } + } + return false; + } + + /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */ + _isSubmerged() { + const bm = this._scene3d?.blockManager; + if (!bm) return false; + // FAST PATH: нет воды на сцене — не утопаем. + if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; + // Проверяем «голову» — точку чуть ниже верха AABB + const headY = this._pos.y + this.HALF_H - 0.1; + const gx = Math.round(this._pos.x); + const gy = Math.floor(headY); + const gz = Math.round(this._pos.z); + const m = bm.blocks.get(`${gx},${gy},${gz}`); + return !!m?.metadata?.isWater; + } + + /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */ + _playFootstep() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + + const now = ctx.currentTime; + const duration = 0.08; + + // Источник — короткий шумовой буфер + const sampleRate = ctx.sampleRate; + const length = Math.floor(sampleRate * duration); + const buffer = ctx.createBuffer(1, length, sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум + } + const src = ctx.createBufferSource(); + src.buffer = buffer; + + // Lowpass для тяжёлого «тук» вместо высокого «шшш» + const lowpass = ctx.createBiquadFilter(); + lowpass.type = 'lowpass'; + lowpass.frequency.value = 350; + lowpass.Q.value = 1.5; + + // Envelope (быстрая атака, быстрое затухание) + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.7, now + 0.005); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + src.connect(lowpass).connect(gain).connect(ctx.destination); + src.start(now); + src.stop(now + duration); + } catch (e) { + // ignore — звук не критичен + } + } + + /** + * Звук прыжка — мягкий «boing» из двух слоёв: + * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами» + * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма» + * Гораздо приятнее старого квадратного восходящего тона. + */ + /** + * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation. + * Работает только для R15-скинов (Kenney-модели эмоций не имеют). + */ + playEmote(name) { + if (this._isR15 && this._r15Animator) { + return this._r15Animator.playEmote(name); + } + return false; + } + + /** Прервать текущую эмоцию персонажа. */ + stopEmote() { + if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote(); + } + + /** + * Проиграть кастомный emote из GLB-spec (см. EmoteGlbParser). + * Используется в preview-режиме теста дизайнерских emote. + */ + playCustomEmote(spec) { + if (this._isR15 && this._r15Animator) { + return this._r15Animator.playCustomEmote(spec); + } + return false; + } + + _playJumpSound() { + // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок + // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся. + if (typeof this._onJump === 'function') { + try { this._onJump(); } catch (e) { /* ignore */ } + } + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const now = ctx.currentTime; + const out = ctx.destination; + + // Слой 1: низкий thump + const thumpDur = 0.07; + const thumpOsc = ctx.createOscillator(); + thumpOsc.type = 'sine'; + thumpOsc.frequency.setValueAtTime(110, now); + thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur); + const thumpGain = ctx.createGain(); + thumpGain.gain.setValueAtTime(0, now); + thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005); + thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur); + thumpOsc.connect(thumpGain).connect(out); + thumpOsc.start(now); + thumpOsc.stop(now + thumpDur + 0.02); + + // Слой 2: «boing» — pitch-down sine + const boingDur = 0.18; + const boingOsc = ctx.createOscillator(); + boingOsc.type = 'sine'; + boingOsc.frequency.setValueAtTime(720, now + 0.005); + boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur); + const boingGain = ctx.createGain(); + boingGain.gain.setValueAtTime(0, now + 0.005); + boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02); + boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur); + // Лёгкое vibrato чтобы было «живее» + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 14; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 18; // ±18 Hz + lfo.connect(lfoGain).connect(boingOsc.frequency); + boingOsc.connect(boingGain).connect(out); + boingOsc.start(now + 0.005); + lfo.start(now + 0.005); + boingOsc.stop(now + 0.005 + boingDur + 0.02); + lfo.stop(now + 0.005 + boingDur + 0.02); + } catch (e) { /* ignore */ } + } + + /** Звук «бульк» при входе/выходе из воды. */ + _playSplashSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const now = ctx.currentTime; + const duration = 0.35; + + // Шум с быстро падающим highpass — звук «всплеска» + const length = Math.floor(ctx.sampleRate * duration); + const buf = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < length; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2; + } + const src = ctx.createBufferSource(); + src.buffer = buf; + + const bp = ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.setValueAtTime(2000, now); + bp.frequency.exponentialRampToValueAtTime(400, now + duration); + bp.Q.value = 1.5; + + const g = ctx.createGain(); + g.gain.setValueAtTime(0, now); + g.gain.linearRampToValueAtTime(0.6, now + 0.01); + g.gain.exponentialRampToValueAtTime(0.001, now + duration); + + src.connect(bp).connect(g).connect(ctx.destination); + src.start(now); + src.stop(now + duration); + } catch (e) { /* ignore */ } + } + + _playAnim(name) { + if (this._currentAnim === name) return; + const target = this._animations[name]; + if (!target) { + // если нужной нет — пробуем idle как fallback + if (name !== 'idle' && this._animations.idle) { + return this._playAnim('idle'); + } + return; + } + // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ === + // Помогает дебажить вибрацию ног на склонах: если в логе видно + // частое мерцание walk↔jump или idle↔walk — значит onGround или + // isMoving скачет каждые несколько кадров. + const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false }; + console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} ` + + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`); + // Стопим текущую + if (this._currentAnim && this._animations[this._currentAnim]) { + this._animations[this._currentAnim].stop(); + } + target.start(/*loop*/ true, /*speed*/ 1); + this._currentAnim = name; + } + + /** + * Остановить режим игры. Освобождает все ресурсы. + */ + stop() { + if (!this._active) return; + this._active = false; + + if (this._beforeRender) { + this.scene.unregisterBeforeRender(this._beforeRender); + this._beforeRender = null; + } + for (const { target, type, fn, opts } of this._listeners) { + target.removeEventListener(type, fn, opts); + } + this._listeners = []; + + if (document.pointerLockElement === this.canvas) { + document.exitPointerLock(); + } + + // Останавливаем все анимации + for (const g of Object.values(this._animations)) { + try { g.stop(); } catch (e) { /* ignore */ } + } + this._animations = {}; + this._currentAnim = null; + + // Удаляем якорь оружия + if (this._weaponAnchor) { + try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ } + this._weaponAnchor = null; + } + // Сбрасываем все override'ы вращения + if (this._meshRotationOverrides) { + this._meshRotationOverrides.clear(); + } + // Сброс R15-состояния + if (this._r15BoneOverrides) this._r15BoneOverrides.clear(); + this._r15Animator = null; + this._r15Skeleton = null; + this._isR15 = false; + + // Удаляем модель + if (this._modelRoot) { + for (const m of this._modelMeshes) { + try { m.dispose(); } catch (e) { /* ignore */ } + } + try { this._modelRoot.dispose(); } catch (e) { /* ignore */ } + this._modelRoot = null; + this._modelMeshes = []; + } + + if (this.camera) { + this.camera.dispose(); + this.camera = null; + } + + if (this._audioCtx) { + try { this._audioCtx.close(); } catch (e) { /* ignore */ } + this._audioCtx = null; + } + } + + isActive() { + return this._active; + } + + // === ВНУТРЕННЕЕ === + + /** Позиция камеры в мире (зависит от режима first/third/front). */ + _computeCameraPos() { + // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up + // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем» + // высоту чтобы камера не дёргалась рывком при step-up. + const visY = this._pos.y - this._stepUpVisualOffset; + if (this._cameraMode === 'first') { + return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z); + } + if (this._cameraMode === 'sideview') { + // Кубикон Dash: камера сбоку, фиксированный yaw на куб. + // Игрок движется по +X, камера в -Z от него (смотрит на +Z). + // С этого ракурса +X на экране = вправо (как в Geometry Dash). + // Лёгкое смещение camera по X влево от куба — игрок в левой + // трети кадра, впереди видно больше уровня. + return new Vector3( + this._pos.x - 1.5, + visY + SIDEVIEW_HEIGHT, + this._pos.z - SIDEVIEW_DIST + ); + } + // forward — направление «куда смотрит игрок» с учётом yaw и pitch + const cosP = Math.cos(this._pitch); + const fx = Math.sin(this._yaw) * cosP; + const fy = -Math.sin(this._pitch); + const fz = Math.cos(this._yaw) * cosP; + const dist = this._thirdDistance; + + // Точка «глаз» игрока — отсюда пускаем луч к запланированной + // позиции камеры и сокращаем дистанцию если упёрлись в стену. + const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET; + + if (this._cameraMode === 'third') { + const desired = new Vector3( + this._pos.x - fx * dist, + eyeY - fy * dist, + this._pos.z - fz * dist + ); + return this._clampCameraToWorld( + this._pos.x, eyeY, this._pos.z, desired + ); + } + // 'front' — спереди игрока, направлена назад (на лицо) + const desiredFront = new Vector3( + this._pos.x + fx * dist, + eyeY + fy * dist, + this._pos.z + fz * dist + ); + return this._clampCameraToWorld( + this._pos.x, eyeY, this._pos.z, desiredFront + ); + } + + /** + * Не позволяет камере «проходить» сквозь стены/блоки/примитивы. + * Пускает луч от глаз игрока до запланированной позиции камеры. + * Если на пути есть препятствие — возвращает точку чуть ближе + * (hit.distance - PADDING), чтобы камера прижалась к стене. + * + * Игнорирует: + * - меши без metadata (вспомогательная техника редактора), + * - триггеры (canCollide===false), + * - саму модель игрока, + * - debris/particles. + */ + _clampCameraToWorld(ex, ey, ez, desired) { + if (!this.scene) return desired; + // Вектор от глаз до желаемой камеры. + const dx = desired.x - ex; + const dy = desired.y - ey; + const dz = desired.z - ez; + const len = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт + const dir = new Vector3(dx / len, dy / len, dz / len); + const origin = new Vector3(ex, ey, ez); + const ray = new Ray(origin, dir, len); + + const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась» + const playerRoot = this._modelRoot; + + const pickPred = (mesh) => { + if (!mesh) return false; + if (!mesh.isEnabled || !mesh.isEnabled()) return false; + // Прозрачно-визуальные и technical meshes — пропускаем. + if (mesh.isPickable === false) return false; + const md = mesh.metadata || {}; + // Триггеры/невидимки/скриптовые маркеры — не блокируют. + if (md.canCollide === false) return false; + if (md._isTriggerHelper) return false; + // Модель игрока (камера не должна цепляться за собственный меш). + if (playerRoot) { + let n = mesh; + while (n) { + if (n === playerRoot) return false; + n = n.parent; + } + } + // Жидкости (вода/лава) — не блокируют камеру. + if (md._liquidProxy) return false; + return true; + }; + + let hit = null; + try { + hit = this.scene.pickWithRay(ray, pickPred); + } catch (e) { + return desired; + } + if (!hit || !hit.hit || hit.distance >= len - 0.01) { + return desired; + } + // Сокращаем дистанцию. + const clampedLen = Math.max(0.3, hit.distance - PADDING); + return new Vector3( + ex + dir.x * clampedLen, + ey + dir.y * clampedLen, + ez + dir.z * clampedLen + ); + } + + // ===== Управление камерой из скрипта (Фаза 5.7) ===== + + /** Установить угол обзора камеры (FOV) в градусах. */ + setCameraFov(degrees) { + if (!this.camera) return; + const d = Number(degrees); + if (!Number.isFinite(d) || d < 10 || d > 130) return; + this.camera.fov = d * Math.PI / 180; + } + + /** + * Привязать камеру к объекту — она смотрит на него. + * getTarget — функция, возвращающая {x,y,z} цели. + * opts: { distance, height } — отступ камеры от цели. + */ + cameraFocusOn(getTarget, opts = {}) { + if (typeof getTarget !== 'function') return; + this._cameraOverride = { + mode: 'focus', + getTarget, + distance: Number.isFinite(opts.distance) ? opts.distance : 8, + height: Number.isFinite(opts.height) ? opts.height : 4, + }; + } + + /** + * Катсцена — плавный пролёт камеры по точкам. + * points — массив {x,y,z} позиций камеры. + * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию). + * segDuration — секунд на отрезок между точками. + * onDone — колбэк по завершении. + */ + cameraCutscene(points, lookAt, segDuration, onDone) { + if (!Array.isArray(points) || points.length < 2) return; + this._cameraOverride = { + mode: 'cutscene', + points, + lookAt: Array.isArray(lookAt) ? lookAt : [], + segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2, + t: 0, // время от начала + seg: 0, // текущий отрезок + onDone: typeof onDone === 'function' ? onDone : null, + }; + } + + /** Вернуть камеру под управление игрока. */ + cameraReset() { + this._cameraOverride = null; + } + + // ===== Задача 14: вождение машины ===== + enterVehicle(veh) { + if (!veh) return; + this._inVehicle = veh; + this._vehicleCamMode = 'follow'; + veh.driver = 'player'; + if (this._codes) this._codes.clear(); + this._skinVisibleScripted = false; + this._startEngineSound(); + } + + // Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум + + // LFO-пульсация тактов), а не воющий тон. Парность со студией. + _startEngineSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + if (this._engineNodes) return; + const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45; + const bufLen = ctx.sampleRate * 1.0; + const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6; + const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true; + const noiseLp = ctx.createBiquadFilter(); noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7; + const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.35; + const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5; + const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 12; + const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.18; + const gain = ctx.createGain(); gain.gain.value = 0.05; + osc.connect(lp); + noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp); + lp.connect(gain); gain.connect(ctx.destination); + lfo.connect(lfoGain); lfoGain.connect(gain.gain); + osc.start(); noise.start(); lfo.start(); + this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain }; + } catch (e) {} + } + _updateEngineSound(speedMs, maxSpeed) { + const n = this._engineNodes; if (!n) return; + try { + const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14)); + const ctx = this._audioCtx; const t = ctx.currentTime; + n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12); + n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12); + n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12); + n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12); + n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12); + n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12); + } catch (e) {} + } + _stopEngineSound() { + const n = this._engineNodes; if (!n) return; + try { + const t = this._audioCtx.currentTime; + n.gain.gain.setTargetAtTime(0, t, 0.05); + n.osc.stop(t + 0.2); n.noise.stop(t + 0.2); n.lfo.stop(t + 0.2); + } catch (e) {} + this._engineNodes = null; + } + exitVehicle() { + const veh = this._inVehicle; + this._inVehicle = null; + if (veh) { + veh.driver = null; + try { + const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw)); + this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0)); + this._vy = 0; + } catch (e) {} + } + this._stopEngineSound(); + this._skinVisibleScripted = true; + if (this._modelMeshes) { + for (const m of this._modelMeshes) { + if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} } + } + } + } + cycleVehicleCamera() { + const modes = ['follow', 'hood', 'cinematic']; + const i = modes.indexOf(this._vehicleCamMode || 'follow'); + this._vehicleCamMode = modes[(i + 1) % modes.length]; + } + _tickVehicle(dt) { + const veh = this._inVehicle; + if (!veh || !this._scene3d?.vehicleManager) return; + if (this._modelMeshes) { + for (const m of this._modelMeshes) { + if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} } + } + } + const c = this._codes; + const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0); + const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0); + const handbrake = c.has('Space'); + this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake); + const _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt); + this._updateEngineSound(veh.speed, veh.params?.maxSpeed); + if (_vres && _vres.fellOut) { + this.exitVehicle(); + if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} } + const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; + try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {} + return; + } + try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {} + if (!this.camera) return; + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const cp = veh.pos; + const mode = this._vehicleCamMode || 'follow'; + let camPos, camTarget; + if (mode === 'hood') { + camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3)); + camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8); + } else if (mode === 'cinematic') { + const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw)); + camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2); + camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z); + } else { + camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8); + camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2); + } + const k = Math.min(1, dt * 6); + this.camera.position.set( + this.camera.position.x + (camPos.x - this.camera.position.x) * k, + this.camera.position.y + (camPos.y - this.camera.position.y) * k, + this.camera.position.z + (camPos.z - this.camera.position.z) * k, + ); + try { this.camera.setTarget(camTarget); } catch (e) {} + } + + /** Применить активный режим камеры скрипта (вызывается в _tick). */ + _applyCameraOverride(dt) { + const o = this._cameraOverride; + if (!o || !this.camera) return; + if (o.mode === 'focus') { + const t = o.getTarget(); + if (!t) return; + // Камера позади-сверху цели, смотрит на неё. + this.camera.position.set(t.x, t.y + o.height, t.z + o.distance); + this.camera.setTarget(new Vector3(t.x, t.y, t.z)); + } else if (o.mode === 'cutscene') { + // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB) + // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается» + // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт + // ровно свою длительность независимо от лагов. + o.t += Math.min(dt, 1 / 30); + const segCount = o.points.length - 1; + // Прогресс по текущему отрезку [0..1]. + let local = o.t / o.segDuration; + let seg = Math.floor(o.t / o.segDuration); + if (seg >= segCount) { + // Катсцена завершена — встаём на последнюю точку. + const last = o.points[o.points.length - 1]; + this.camera.position.set(last.x, last.y, last.z); + const lookLast = o.lookAt[o.lookAt.length - 1]; + if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z)); + const cb = o.onDone; + this._cameraOverride = null; + if (cb) { try { cb(); } catch (e) { /* ignore */ } } + return; + } + local = local - seg; // дробная часть = прогресс отрезка + // Сглаживание (ease-in-out) — плавный пролёт. + const k = local < 0.5 + ? 2 * local * local + : 1 - Math.pow(-2 * local + 2, 2) / 2; + const a = o.points[seg], b = o.points[seg + 1]; + this.camera.position.set( + a.x + (b.x - a.x) * k, + a.y + (b.y - a.y) * k, + a.z + (b.z - a.z) * k, + ); + // Точка взгляда — интерполяция между соседними lookAt. + const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg]; + if (la && lb) { + this.camera.setTarget(new Vector3( + la.x + (lb.x - la.x) * k, + la.y + (lb.y - la.y) * k, + la.z + (lb.z - la.z) * k, + )); + } + } + } + + /** + * Применить текущий режим камеры: + * - В 1st person скрываем модель игрока (видим только сцену) + * - В 3rd person и front показываем + */ + _applyCameraMode() { + const visible = this._cameraMode !== 'first'; + for (const m of this._modelMeshes) { + m.setEnabled(visible); + } + // Сообщаем оружию что режим камеры сменился — чтобы перепарентить + // view-model (камера в 1-st, модель игрока в 3-rd). + if (this._scene3d?.weapons?.onCameraModeChange) { + this._scene3d.weapons.onCameraModeChange(this._cameraMode); + } + } + + /** + * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели. + * + * Установить override-rotation для именованного меша поверх анимации. + * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion, + * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет + * наши углы Эйлера. + * + * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...). + * Без префикса 'player_'. + * rotation — Vector3 углов Эйлера (rad). null → снять override. + * + * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты). + */ + setMeshRotationOverride(meshName, rotation) { + // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт + // этот метод для позы/замаха — переадресуем на override кости. + // R15Animator каждый кадр ставит rest+анимацию; override кости + // применяется поверх в _applyR15BoneOverrides() после update(). + if (this._isR15) { + if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map(); + // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость. + const lower = (meshName || '').toLowerCase(); + const logical = (lower.includes('right')) ? 'RightUpperArm' + : (lower.includes('left')) ? 'LeftUpperArm' + : 'RightUpperArm'; + if (rotation == null) { + this._r15BoneOverrides.delete(logical); + } else { + this._r15BoneOverrides.set(logical, + rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); + } + return; + } + if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map(); + if (rotation == null) { + this._meshRotationOverrides.delete(meshName); + } else { + this._meshRotationOverrides.set(meshName, + rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); + } + } + + /** + * Применить override-повороты костей R15 поверх процедурной анимации. + * Вызывается после R15Animator.update(). Используется WeaponSystem + * для позы руки с оружием / melee-замаха. + */ + _applyR15BoneOverrides() { + const map = this._r15BoneOverrides; + if (!map || map.size === 0 || !this._r15Skeleton) return; + for (const [logical, rot] of map.entries()) { + const bone = this._r15Skeleton.resolveBone(logical); + if (!bone) continue; + // Override задаётся как абсолютный локальный поворот кости + // (Эйлер). Перекрывает то, что поставил аниматор этим кадром. + const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z); + bone.setRotationQuaternion(q, Space.LOCAL); + } + } + + /** Получить меш модели по короткому имени (без префикса 'player_'). */ + getModelMesh(meshName) { + if (!this._modelMeshes) return null; + const target = `player_${meshName}`; + return this._modelMeshes.find(m => m.name === target) || null; + } + + /** + * Применить все активные override'ы. Вызывается каждый кадр в _tick + * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после + * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его + * каждый кадр и пишем в .rotation. + */ + _applyMeshRotationOverrides() { + const map = this._meshRotationOverrides; + if (!map || map.size === 0) return; + for (const [meshName, rot] of map.entries()) { + const mesh = this.getModelMesh(meshName); + if (!mesh) continue; + if (mesh.rotationQuaternion) { + mesh.rotationQuaternion = null; + } + mesh.rotation.x = rot.x; + mesh.rotation.y = rot.y; + mesh.rotation.z = rot.z; + } + } + + /** + * Включить/выключить «позу с оружием». + * Делает 2 вещи независимо: + * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney). + * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает + * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0, + * и дуло автоматически смотрит вперёд персонажа. + * + * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять + * ориентацию повёрнутого меша руки. + */ + _updateExtendedArm(hasWeapon) { + // === R15-скин: якорь оружия на кости RightHand === + // У R15 нет меша-руки — есть кость. Якорь привязываем к кости + // через attachToBone: оружие следует за рукой при анимации. + if (this._isR15 && this._r15Skeleton) { + const showWeapon = hasWeapon && this._cameraMode !== 'first'; + if (showWeapon && !this._weaponAnchor) { + const handBone = this._r15Skeleton.resolveBone('RightHand'); + const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0]; + if (handBone && skinMesh) { + this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); + // attachToBone — якорь следует за костью каждый кадр. + this._weaponAnchor.attachToBone(handBone, skinMesh); + // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье. + this._weaponAnchor.position.set(0, 0.05, 0.1); + } + } + if (this._weaponAnchor) { + this._weaponAnchor.setEnabled(showWeapon); + } + return; + } + + const armMesh = this._rightArmMeshes?.[0]; + if (!armMesh) return; + const meshName = (armMesh.name || '').replace(/^player_/, ''); + const showWeapon = hasWeapon && this._cameraMode !== 'first'; + + // 1) Поза руки через override + if (showWeapon) { + this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0)); + } else { + this.setMeshRotationOverride(meshName, null); + } + + // 2) ChIstый якорь для оружия — TransformNode на плече персонажа, + // ориентация совпадает с _modelRoot (без поворотов). + if (showWeapon && !this._weaponAnchor && this._modelRoot) { + this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); + this._weaponAnchor.parent = this._modelRoot; + // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша. + // Плечо: y = origin + 0.7 (выше). + // X: сдвигаем чуть наружу (ещё правее). + const sx = -(armMesh.position?.x ?? -0.4) + 0.15; + const sy = (armMesh.position?.y ?? 1.1) + 0.7; + const sz = (armMesh.position?.z ?? 0) + 0.95; + this._weaponAnchor.position.set(sx, sy, sz); + } + if (this._weaponAnchor) { + this._weaponAnchor.setEnabled(showWeapon); + } + } + + getWeaponAnchor() { return this._weaponAnchor || null; } + + /** Цикл first ↔ third. */ + _toggleCameraMode() { + const idx = CAMERA_MODES.indexOf(this._cameraMode); + this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; + this._applyCameraMode(); + } + + /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус + * всегда лицом к камере, камера через плечо). + */ + setShiftLock(on) { + this._shiftLock = !!on; + if (this._shiftLock) { + // Запросить pointer-lock — курсор в центре + this._requestPointerLockSafe(); + } else { + // Снять lock если он есть и нет других причин держать (first/sideview) + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' + ); + if (!needPermLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + this._applyCursorVisibility?.(); + } + isShiftLock() { return !!this._shiftLock; } + + /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc). + * Не блокирует Esc/Tab/Enter (нужны для GUI). + * Также сбрасывает накопленные клавиши чтобы движение остановилось. */ + setInputBlocked(blocked) { + this._inputBlocked = !!blocked; + if (this._inputBlocked) { + try { this._codes?.clear(); } catch (e) {} + this._shift = false; + // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры» + try { + if (document.pointerLockElement === this.canvas) document.exitPointerLock(); + } catch (e) {} + } + } + isInputBlocked() { return !!this._inputBlocked; } + + /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */ + setCameraFrozen(frozen) { + this._cameraFrozen = !!frozen; + } + isCameraFrozen() { return !!this._cameraFrozen; } + + /** Задача 04: снимок состояния камеры — для восстановления после модала. */ + captureCameraState() { + return { + yaw: this._yaw, + pitch: this._pitch, + cameraMode: this._cameraMode, + thirdDistance: this._thirdDistance, + fov: this.scene?.activeCamera?.fov, + playerPos: this._pos ? { + x: this._pos.x, y: this._pos.y, z: this._pos.z + } : null, + }; + } + + /** Задача 04: восстановить состояние камеры из снимка. */ + restoreCameraState(s) { + if (!s) return; + if (Number.isFinite(s.yaw)) this._yaw = s.yaw; + if (Number.isFinite(s.pitch)) this._pitch = s.pitch; + if (s.cameraMode) { + this._cameraMode = s.cameraMode; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance; + if (Number.isFinite(s.fov) && this.scene?.activeCamera) { + this.scene.activeCamera.fov = s.fov; + } + } + + /** Задача 04: камера-фокус на reference (cube/npc/cam-target). + * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}. + * Использует уже существующий механизм camera.focus в GameRuntime, но + * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель, + * и зум на distance. */ + focusOnTarget(ref, opts) { + opts = opts || {}; + const distance = Number.isFinite(opts.distance) ? opts.distance : 8; + const height = Number.isFinite(opts.height) ? opts.height : 3; + const fov = Number.isFinite(opts.fov) ? opts.fov : null; + let target = null; + if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) { + target = ref; + } else { + const m = this._resolveTargetMesh(ref); + if (m) { + const p = m.getAbsolutePosition?.() || m.position; + target = { x: p.x, y: p.y, z: p.z }; + } + } + if (!target) return; + // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target + // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch. + if (!this._pos) return; + const dx = target.x - this._pos.x; + const dz = target.z - this._pos.z; + const dy = target.y - this._pos.y; + const horiz = Math.hypot(dx, dz); + this._yaw = Math.atan2(dx, dz); + this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz))); + this._thirdDistance = distance; + if (this._cameraMode !== 'third') { + this._cameraMode = 'third'; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (fov && this.scene?.activeCamera) { + this.scene.activeCamera.fov = fov * Math.PI / 180; + } + } + + _resolveTargetMesh(ref) { + if (!ref) return null; + if (ref.getScene && typeof ref.getScene === 'function') return ref; + const sc = this._scene3d || this.scene3d; + const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); + if (!idStr || !sc) return null; + const tries = [ + () => sc.primitiveManager?.getMesh?.(idStr), + () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0], + () => sc.scene?.getMeshByName?.(idStr), + () => sc.npcManager?.getMeshes?.(idStr)?.[0], + ]; + for (const fn of tries) { + try { const r = fn(); if (r) return r; } catch (e) {} + } + return null; + } + + /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, d)); + // Авто-переход third↔first если пересекли порог + if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'third') { + this._cameraMode = 'first'; + this._applyCameraMode?.(); + this._requestPointerLockSafe(); + } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'first' && !this._lockFirstPerson) { + this._cameraMode = 'third'; + this._applyCameraMode?.(); + if (!this._shiftLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + } + /** Установить границы зума колеса. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn; + if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx; + // Перекламп текущей дистанции + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance)); + } + + _setupInput() { + const canvas = this.canvas; + + // Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock. + const needPermLock = () => ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + + const onCanvasClick = () => { + // В UI-режиме клик не перехватывает мышь. + if (this._uiCursorMode) return; + if (!this._active) return; + // Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся + // свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов + // где курсор постоянно скрыт, и только если lock был снят. + if (!needPermLock()) return; + if (document.pointerLockElement !== canvas) { + try { + const p = canvas.requestPointerLock?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (e) { /* ignore */ } + } + }; + 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-игр) === + const onCanvasMouseDown = (e) => { + if (!this._uiCursorMode) return; + if (typeof this._uiMouseDownCb !== 'function') return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { + try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ } + } + }; + const onCanvasMouseUp = (e) => { + if (!this._uiCursorMode) return; + if (typeof this._uiMouseUpCb !== 'function') return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ } + }; + canvas.addEventListener('mousedown', onCanvasMouseDown); + // mouseup ловим на document — мышь могла уйти за пределы канваса + document.addEventListener('mouseup', onCanvasMouseUp); + + const onMouseMove = (e) => { + // === UI-режим: транслируем нормализованные [0..1] координаты === + // подписчику (Worker через GameRuntime). Используется для drag-игр + // типа Дальгона. + if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + // Кидаем только если внутри канваса + if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { + try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ } + } + } + if (document.pointerLockElement !== canvas) return; + // Кубикон Dash: в sideview мышь не вращает камеру. + if (this._cameraMode === 'sideview') return; + // Задача 04: модал с freezeCamera — мышь не вращает. + if (this._cameraFrozen) return; + this._yaw += e.movementX * this.MOUSE_SENSITIVITY; + // _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y. + const pitchSign = this._invertCamera ? -1 : 1; + this._pitch += e.movementY * this.MOUSE_SENSITIVITY * pitchSign; + const lim = Math.PI / 2 - 0.05; + if (this._pitch > lim) this._pitch = lim; + if (this._pitch < -lim) this._pitch = -lim; + }; + document.addEventListener('mousemove', onMouseMove); + + // Задача 02: колесо = зум third-камеры с авто-переходом third↔first. + const onWheel = (e) => { + if (!this._active) return; + if (this._cameraFrozen) { e.preventDefault(); return; } // модал + if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD + // В first зум наружу возвращает в third (если не lockfirst). + 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_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(); + }; + canvas.addEventListener('wheel', onWheel, { passive: false }); + + let wasLocked = false; + const onPointerLockChange = () => { + const locked = document.pointerLockElement === canvas; + this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор + if (locked) { + wasLocked = true; + this._rmbHeld = true; // если попали в lock — ПКМ удерживается + } else if (wasLocked && this._active) { + // pointer-lock снят. Причин три: + // 1) пользователь в UI-режиме (game.input.setCursorMode('ui')) + // 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); + + const isTypingTarget = (target) => { + if (!target) return false; + const tag = (target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + return !!target.isContentEditable; + }; + const onKeyDown = (e) => { + if (!this._active) return; + if (isTypingTarget(e.target)) return; + // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest + // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc + // в third (без pointer-lock) сразу выходил из Play. + if (e.code === 'Escape') { + if (this._onExitRequest) { + this._onExitRequest(); + return; + } + } + // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.), + // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик), + // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах). + if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') { + // Глотаем preventDefault только для игровых клавиш + if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); + return; + } + this._codes.add(e.code); + // Задача 14: в машине — V камера, E выход. + if (this._inVehicle) { + if (e.code === 'KeyV') { this.cycleVehicleCamera(); } + else if (e.code === 'KeyE') { + const veh = this._inVehicle; + this.exitVehicle(); + if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} } + } + } + if (e.shiftKey) this._shift = true; + // C — переключение first/third. Отключаем в GD-режиме (автобег > 0) + // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview. + if (e.code === 'KeyC') { + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + if (!inGdMode) this._toggleCameraMode(); + } + // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег, + // поэтому переназначено на L). Курсор центрируется, корпус всегда + // лицом к камере, камера через плечо. + if (e.code === 'KeyL') { + this.setShiftLock(!this._shiftLock); + } + // B — встроенный магазин скинов (задача 07). Открывается только если + // включён в проекте (scene.skins.shopVisible). Toggle. + if (e.code === 'KeyB' && !this._inputBlocked) { + try { this._scene3d?.toggleSkinShop?.(); } catch (err) {} + } + // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) + if (e.code === 'Tab') { + e.preventDefault(); + this.setUiCursorMode(!this._uiCursorMode); + } + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { + e.preventDefault(); + } + // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус), + // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек. + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) { + e.preventDefault(); + } + }; + const onKeyUp = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.delete(e.code); + if (!e.shiftKey) this._shift = false; + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + const onBlur = () => { + this._codes.clear(); + this._shift = false; + }; + window.addEventListener('blur', onBlur); + + this._listeners = [ + { target: canvas, type: 'click', fn: onCanvasClick }, + { target: canvas, type: 'wheel', fn: onWheel, + opts: { passive: false } }, + { target: document, type: 'mousemove', fn: onMouseMove }, + { target: document, type: 'pointerlockchange', fn: onPointerLockChange }, + { target: window, type: 'keydown', fn: onKeyDown }, + { target: window, type: 'keyup', fn: onKeyUp }, + { target: window, type: 'blur', fn: onBlur }, + ]; + } + + _tick() { + // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая + // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж + // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1 + // (max 10 кадров/сек физики). Этого хватает чтобы движение было + // плавным даже на 5 FPS — просто чуть рывками. + let dt = this.scene.getEngine().getDeltaTime() / 1000; + if (dt <= 0) return; + if (dt > 0.1) dt = 0.1; + + // === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу === + if (this._inVehicle) { + try { this._tickVehicle(dt); } catch (e) { /* ignore */ } + return; + } + + // === Присед: по Ctrl на десктопе, или через мобильную кнопку + // (которая шлёт keydown 'ControlLeft'). C — НЕ используется + // (это смена вида в Babylon). + // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику + // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship). + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + const wantCrouch = !inGdMode && this._codes + && (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); + if (wantCrouch && !this._crouching) { + this._crouching = true; + // сдвигаем центр капсулы вниз — низ ног остаётся на земле + const dH = this.HALF_H_CROUCH - this.HALF_H; + this.HALF_H = this.HALF_H_CROUCH; + if (this._pos) this._pos.y += dH; + } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { + this._crouching = false; + const dH = this.HALF_H_NORMAL - this.HALF_H; + this.HALF_H = this.HALF_H_NORMAL; + if (this._pos) this._pos.y += dH; + } + + // === Горизонтальное движение === + const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); + const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); + const isSprinting = this._shift; + const speedMult = isSprinting ? this.SPRINT_MULT : 1; + const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; + + let moveX = 0, moveZ = 0; + // c (codes) используется ниже для прыжка/Space — объявляем здесь, + // чтобы был доступен после if/else движения. + const c = this._codes; + const am = this._analogMove; + // === Кубикон Dash: авто-движение по +X, ввод заблокирован === + // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме + // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются. + if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) { + moveX = this._autoRunSpeed * dt; + moveZ = 0; + } else + // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z === + // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит + // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО — + // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал + // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос + // делает скрипт через player.teleport по X. + if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) { + moveX = 0; + moveZ = this._autoRunSpeed * dt; + } else + // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами: + // позволяет двигаться в любом направлении плавно, не только в 8 секторов. + if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) { + // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*). + // *На практике мы передаём sprint через setVirtualShift. + const mag = Math.min(1, Math.hypot(am.x, am.y)); + // Нормализуем направление, скорость = mag * speed + const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y)); + const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y)); + const v = mag * speed; + // y=1 → вперёд (forward), x=1 → вправо (right) + moveX = forward.x * dirY * v + right.x * dirX * v; + moveZ = forward.z * dirY * v + right.z * dirX * v; + } else { + if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; } + if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; } + if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; } + if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; } + } + + const isMoving = (moveX !== 0 || moveZ !== 0); + + // === ЛЁД: инерция движения === + // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость + // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр) + // с коэффициентом ускорения, и затухает с (1 - friction*dt*8). + if (this._iceFriction > 0.001) { + const fric = Math.min(1, this._iceFriction); + // Чем выше friction, тем медленнее набираем скорость и тем дольше + // затухает после отпускания клавиш. + const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45 + const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1 + // Целевая скорость = moveX/moveZ (уже содержит dt) + // Прибавляем разницу с ускорением + this._iceVelX += (moveX - this._iceVelX) * accel; + this._iceVelZ += (moveZ - this._iceVelZ) * accel; + if (!isMoving) { + this._iceVelX *= decay; + this._iceVelZ *= decay; + if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0; + if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0; + } + moveX = this._iceVelX; + moveZ = this._iceVelZ; + } else { + // Сбрасываем накопленную скорость когда лёд выкл + this._iceVelX = 0; + this._iceVelZ = 0; + } + + // === Плавание (если AABB пересекает блок-воду) === + const inWater = this._isInWater(); + const submerged = this._isSubmerged(); + if (inWater) { + // В воде движемся в 2 раза медленнее + moveX *= 0.5; + moveZ *= 0.5; + } + + // === Вертикальное === + if (inWater) { + // Плавание: лёгкая гравитация + плавучесть к поверхности + const buoyancy = submerged ? 6 : 0; + const swimGravity = -3; + this._vy += (buoyancy + swimGravity) * dt; + this._vy *= Math.max(0, 1 - 3 * dt); + if (this._codes.has('Space')) this._vy += 14 * dt; + if (this._vy > 4) this._vy = 4; + if (this._vy < -4) this._vy = -4; + } else { + // Кубикон Dash: умеренная усиленная гравитация для коротких + // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с + // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2, + // не слишком улетает по X (~4-5м за прыжок). + // Variable-jump (отпускание Space обрезает vy) НЕ используется — + // в авто-беге игрок не контролирует длительность нажатия. + // + // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ + // к потолку. Кап-скорости тоже инвертируется. + const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0; + const userGravityMul = this._gravityMul || 1; + const gDir = this._gravityDir || 1; + + if (this._waveMode) { + // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|). + // Гравитация полностью игнорируется — линейное движение по диагонали. + const speed = Math.max(1, this._autoRunSpeed || 8); + this._vy = this._jumpHeld ? speed : -speed; + } else { + this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt; + + // SHIP-режим: при удержании Space даём импульс ВВЕРХ + // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс + // делает плавный полёт. + if (this._shipMode && this._jumpHeld) { + // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался, + // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36) + const SHIP_THRUST = 80; // м/с² против гравитации + this._vy += SHIP_THRUST * gDir * dt; + } + // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем + // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается. + if (this._robotMode && this._robotBoostLeft > 0) { + if (c.has('Space')) { + // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно. + // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену). + this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt; + this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt); + } else { + this._robotBoostLeft = 0; + } + } + + // Cap: ±50 в любом направлении (для Ship — мягче, ±25) + const vyCap = this._shipMode ? 25 : 50; + if (this._vy < -vyCap) this._vy = -vyCap; + if (this._vy > vyCap) this._vy = vyCap; + } + } + + const beforeX = this._pos.x, beforeZ = this._pos.z; + + // === STICK к движущейся платформе === + // Если в прошлом кадре стояли на примитиве/модели, и она двигается — + // двигаем игрока вместе с ней (по дельте позиции платформы за кадр). + if (this._lastGroundData && this._lastGroundPos) { + const gd = this._lastGroundData; + // Текущая позиция платформы — берём из live-data (она обновляется + // movingPlatforms-скриптом через scene.move). + const curX = gd.x, curY = gd.y, curZ = gd.z; + const dPlatX = curX - this._lastGroundPos.x; + const dPlatY = curY - this._lastGroundPos.y; + const dPlatZ = curZ - this._lastGroundPos.z; + // Применяем дельту только если она разумная (защита от телепорта + // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.) + if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) { + this._pos.x += dPlatX; + this._pos.y += dPlatY; + this._pos.z += dPlatZ; + } + } + + // PERF-METRICS: замер физики игрока + const _pt0 = performance.now(); + const result = this.physics.moveAABB( + this._pos, this.HALF_W, this.HALF_H, this.HALF_D, + moveX, this._vy * dt, moveZ + ); + const _bs = this._scene3d || this.scene3d; + if (_bs && _bs._perfMetrics) { + _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; + _bs._perfMetrics.physics_count++; + } + this._pos.set(result.x, result.y, result.z); + if (result.hitY) this._vy = 0; + // Surface-follow на smooth-terrain прижал нас к склону — гравитация + // больше не нужна на этом кадре, иначе будет вибрация от соревнования + // (гравитация тянет вниз → следующий кадр surface поднимает обратно). + if (result.surfaceFollowed) this._vy = 0; + + // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы + // визуально плавно подняться. Физика уже сделала телепорт вверх, + // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже + // в кадровой логике (раз за tick). + if (result.steppedUpBy && result.steppedUpBy > 0) { + this._stepUpVisualOffset += result.steppedUpBy; + // Кэп — не более 1.2м, иначе при множественных степах подряд + // оффсет может стать гигантским и аватар «уйдёт под землю». + if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2; + } + // Спадание оффсета. dt — реальное время тика (секунды). + if (this._stepUpVisualOffset > 0) { + this._stepUpVisualOffset -= this._stepUpDecay * dt; + if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0; + } + + // Запоминаем «на чём стоим» для следующего кадра + if (result.onGround && result.groundData?.data) { + this._lastGroundData = result.groundData.data; + this._lastGroundPos = { + x: result.groundData.data.x, + y: result.groundData.data.y, + z: result.groundData.data.z, + }; + } else { + this._lastGroundData = null; + this._lastGroundPos = null; + } + + // === Авто-вылезание на берег из воды === + // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться — + // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание». + if (inWater && isMoving && (result.hitX || result.hitZ)) { + this._vy = Math.max(this._vy, 5); + } + + // Респавн если игрок упал в пустоту (за пределы baseplate) + if (this._pos.y < -30) { + const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; + this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z); + this._vy = 0; + } + + // === Push unanchored объектов при пересечении === + // Скорость игрока в этом кадре (для направления толчка). + // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0, + // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD) + // и forward камеры — DynamicsManager выберет лучшее направление. + const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt); + const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt); + const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ); + const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal); + // Берём "желаемое" движение если оно больше реального (игрок упёрся, + // но WASD нажаты — значит "пытается толкать"). + const useDesired = desiredSpeed > realSpeed + 0.5; + const pushVx = useDesired ? moveX / dt : playerVxReal; + const pushVz = useDesired ? moveZ / dt : playerVzReal; + if (this._scene3d?.dynamics?.isEnabled?.()) { + this._scene3d.dynamics.applyPushFromPlayer( + this._pos.x, this._pos.y, this._pos.z, + this.HALF_W, this.HALF_H, this.HALF_D, + pushVx, pushVz, + forward.x, forward.z, + playerVxReal, playerVzReal + ); + } + + // === Чекпоинты — обновить точку спавна при касании === + if (this.physics?.getOverlappingPrimitives) { + const overlaps = this.physics.getOverlappingPrimitives( + this._pos.x, this._pos.y, this._pos.z, + this.HALF_W, this.HALF_H, this.HALF_D + ); + for (const data of overlaps) { + if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) { + this._activatedCheckpoints.add(data.id); + if (this._scene3d) { + // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта + // чтобы при респавне игрок не попал внутрь чекпоинта. + this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z); + } + this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь») + } + } + } + + // Шаги — копим пройденную горизонтальную дистанцию когда onGround + if (result.onGround && isMoving) { + const dxReal = this._pos.x - beforeX; + const dzReal = this._pos.z - beforeZ; + this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal); + const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK; + if (this._distanceSinceLastStep >= stepThreshold) { + this._distanceSinceLastStep = 0; + this._playFootstep(); + } + } else { + // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки + // не воспроизвёлся слишком рано. + this._distanceSinceLastStep = 0; + } + + // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть. + // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже + // края, понимает что упал в яму, бесит. С коянот-окном — прощает. + // + // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом», + // используем result.onCeiling вместо onGround. + const gDir = this._gravityDir || 1; + const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false); + if (effGround) { + this._coyoteLeft = 0.12; + } else if (this._coyoteLeft > 0) { + this._coyoteLeft -= dt; + } + const canJump = effGround || this._coyoteLeft > 0; + // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде. + // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации). + // В Ship-режиме обычный jump-импульс отключён — корабль управляется + // только удержанием Space (см. блок _shipMode выше). _jumpHeld + // обновляем чтобы остальная логика (release, double-jump) работала. + if (this._waveMode) { + // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block). + // _jumpHeld для синхронизации с состоянием Space. + this._jumpHeld = c.has('Space'); + } else + if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { + if (!this._jumpHeld) { + // Robot — стартовый импульс полный (как куб) для тапа достаточный, + // boost-фаза 0.45с удлиняет подъём при удержании Space. + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; + this._playJumpSound(); + this._jumpHeld = true; + this._coyoteLeft = 0; + // Robot: запускаем boost-фазу на 0.45с + if (this._robotMode) { + this._robotBoostLeft = 0.45; + } + } + } else if (this._shipMode && c.has('Space')) { + this._jumpHeld = true; + } else if (this._ufoMode && c.has('Space') && !inWater) { + // UFO: каждый отдельный тап = микропрыжок (даже в воздухе). + // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата. + if (!this._jumpHeld) { + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85; + this._playJumpSound(); + this._jumpHeld = true; + } + } + // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка + if (effGround) this._doubleJumpUsed = false; + // Двойной прыжок: в воздухе и Space нажат отдельно (после release). + if (!effGround && !inWater && c.has('Space') + && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) { + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; + this._playJumpSound(); + this._doubleJumpUsed = true; + this._jumpHeld = true; + } + if (!c.has('Space')) this._jumpHeld = false; + + // Звук всплеска при входе/выходе из воды + if (this._wasInWater !== inWater) { + this._playSplashSound(); + this._wasInWater = inWater; + } + + // Состояние игрока для game.player.state ('ground'|'air'|'water'). + this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air'); + // Хук game.onPlayerLand — был в воздухе, коснулся земли. + if (effGround && this._wasOnGround === false && !inWater) { + if (typeof this._onLand === 'function') { + try { this._onLand(); } catch (e) { /* ignore */ } + } + } + this._wasOnGround = effGround; + + // === Камера === + this.camera.position = this._computeCameraPos(); + // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную. + if (this._cameraOverride) { + this._applyCameraOverride(dt); + } + // Camera shake — небольшое случайное смещение по X/Y, затухает. + if (this._cameraShakeLeft > 0) { + this._cameraShakeLeft -= dt; + const t = Math.max(0, this._cameraShakeLeft); + const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка + this.camera.position.x += (Math.random() - 0.5) * amp; + this.camera.position.y += (Math.random() - 0.5) * amp; + } + // Скрипт-управление камерой (cutscene/focus) уже выставило И + // позицию, И направление взгляда через setTarget. НЕ трогаем + // camera.rotation — иначе setTarget затирается и катсцена + // «смотрит прямо» вместо вращения по точкам lookAt. + if (!this._cameraOverride) { + if (this._cameraMode === 'front') { + // Камера смотрит назад на игрока — yaw на 180°, pitch инверт. + this.camera.rotation.x = -this._pitch; + this.camera.rotation.y = this._yaw + Math.PI; + } else if (this._cameraMode === 'sideview') { + // Sideview: камера в -Z от игрока, смотрит на +Z. + this.camera.rotation.x = 0; + this.camera.rotation.y = 0; + this.camera.rotation.z = 0; + } else { + this.camera.rotation.x = this._pitch; + this.camera.rotation.y = this._yaw; + } + if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0; + } + + // === Модель игрока === + if (this._modelRoot) { + // === Поза: в воде персонаж лежит горизонтально (плавает) === + // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция. + const targetSwimTilt = inWater ? Math.PI / 2 : 0; + const swimTilt = this._swimTilt ?? 0; + const tiltStep = 4 * dt; // 4 рад/с скорость наклона + let nextTilt = swimTilt; + if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) { + nextTilt = targetSwimTilt; + } else { + nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep; + } + this._swimTilt = nextTilt; + + // В воде наклон 90° кладёт модель горизонтально. yLift поднимает + // root к центру AABB. Также при наклоне корень оказывается в позиции + // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root + // на длину модели вперёд по yaw чтобы голова была впереди. + const tiltFrac = nextTilt / (Math.PI / 2); // 0..1 + const yLift = inWater ? this.HALF_H * tiltFrac : 0; + const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела + const fwdShift = inWater ? bodyLen * tiltFrac : 0; + const fx = Math.sin(this._modelYaw); + const fz = Math.cos(this._modelYaw); + this._modelRoot.position.set( + this._pos.x + fx * fwdShift, + this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset, + this._pos.z + fz * fwdShift + ); + + // Поворот модели: + // - на суше: направление РЕАЛЬНОГО движения (как было). + // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто + // двигает тело вбок без вращения, как на суше при first-person. + if (inWater) { + const targetYaw = this._yaw; + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt * 2; + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } else { + const dxReal = this._pos.x - beforeX; + const dzReal = this._pos.z - beforeZ; + const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; + if (movedHorizontal) { + const targetYaw = Math.atan2(dxReal, dzReal); + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt; + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } + } + // Применяем yaw + swim-tilt. + // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит + // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift). + this._modelRoot.rotation.y = this._modelYaw; + this._modelRoot.rotation.x = nextTilt; + // В воде также добавляем лёгкое покачивание по Z (как волна тела) + if (inWater) { + const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15; + this._modelRoot.rotation.z = wobble; + } else if (this._cameraMode === 'sideview') { + // Кубикон Dash: в воздухе куб крутится назад (по часовой если + // смотреть с -Z), на земле плавно возвращается в 0. + // Скорость подобрана так чтобы между прыжками куб успевал + // совершить ~1 оборот. + const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с + if (!result.onGround) { + this._dashSpinAngle -= SPIN_SPEED * dt; + } else { + // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0) + const TAU = Math.PI * 2; + const target = Math.round(this._dashSpinAngle / TAU) * TAU; + const diff = target - this._dashSpinAngle; + const snapStep = SPIN_SPEED * 1.5 * dt; + if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target; + else this._dashSpinAngle += Math.sign(diff) * snapStep; + } + this._modelRoot.rotation.z = this._dashSpinAngle; + } else { + this._modelRoot.rotation.z = 0; + } + // Поза с оружием — обновляем флаг каждый кадр (на случай смены) + const hasWeapon = !!this._scene3d?.weapons?._equipped; + this._updateExtendedArm(hasWeapon); + // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций. + // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем + // и пишем свои углы Эйлера. Это ключевой момент: вызывается + // каждый кадр, поэтому override переживает анимацию. + this._applyMeshRotationOverrides(); + } + + // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)). + // Применяем каждый кадр — на случай если меши только что асинхронно + // загрузились, либо _applyCameraMode перезаписал enabled=true. + if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) { + for (let i = 0; i < this._modelMeshes.length; i++) { + const m = this._modelMeshes[i]; + if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { + try { m.setEnabled(false); } catch (e) {} + } + } + } + + // Тик распадающихся кусков игрока (после смерти) + this._tickDebris(dt); + + // === Анимации === + // Снимок скорости/опоры для процедурной анимации non-humanoid скинов. + this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1); + this._isGrounded = !!result.onGround; + + // Non-humanoid single-mesh скин: костей нет — анимируем процедурно + // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них. + if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) { + this._animateNonHumanoidMesh(dt); + return; + } + + // R15-скин: процедурный аниматор (нет glTF AnimationGroups). + // Состояния: idle/walk/run/jump/fall. sprint → run. + if (this._isR15 && this._r15Animator) { + let r15State; + if (!result.onGround) { + // vy > 0 — вверх (jump-поза с поджатыми ногами), + // vy < 0 — вниз (fall, лёгкий наклон корпуса). + r15State = (this._vy > 0.5) ? 'jump' : 'fall'; + } else if (inWater) { + r15State = isMoving ? 'walk' : 'idle'; + } else if (isMoving) { + r15State = isSprinting ? 'run' : 'walk'; + } else { + r15State = 'idle'; + } + this._r15Animator.setState(r15State); + this._r15Animator.update(dt); + // Override костей поверх анимации (поза руки с оружием/замах). + this._applyR15BoneOverrides(); + return; // R15 не использует AnimationGroups-путь ниже + } + + let nextAnim; + if (inWater) { + // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе + nextAnim = isMoving ? 'walk' : 'idle'; + } else if (!result.onGround) { + nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle'); + } else if (isMoving) { + nextAnim = isSprinting ? 'sprint' : 'walk'; + } else { + nextAnim = 'idle'; + } + // Снимок состояния для лога в _playAnim + this._lastAnimDebug = { + vy: this._vy, + og: !!result.onGround, + sf: !!result.surfaceFollowed, + mv: !!isMoving, + }; + // === Периодический trace состояния (раз в 30 кадров ~0.5с) === + // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация + // не меняется — полезно для разбора "вибрации". + if (!this._animTraceCnt) this._animTraceCnt = 0; + this._animTraceCnt++; + if (this._animTraceCnt >= 30) { + this._animTraceCnt = 0; + const d = this._lastAnimDebug; + console.log(`[AnimTrace] anim=${this._currentAnim} ` + + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`); + } + this._playAnim(nextAnim); + } +} diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index 286be21..a2da5b4 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -1,3726 +1,4139 @@ -/** - * ScriptSandboxWorker.js — код, исполняющийся внутри Web Worker. - * - * НЕ импортируется напрямую через ES-import. Загружается через blob-URL, - * созданный в ScriptSandbox.js. - * - * Архитектура: пользовательский скрипт получает ТОЛЬКО объект `game`. - * Любая операция (двигать игрока, лог) превращается в команду - * postMessage в main thread. Main thread исполняет на Babylon-сцене и - * присылает обратно state-update'ы. - * - * API (этап 2.3.1): - * game.player.position — {x, y, z}, обновляется main thread'ом - * game.player.teleport(x, y, z) - * game.onTick(fn) — fn(dt) каждый кадр - * game.log(...args) — лог в Console - * - * game.self — {kind, ref, position} — объект-носитель - * (только для скриптов с target) - * game.self.onClick(fn) — fn(event) при клике по объекту в Play - * game.self.onTouch(fn) — fn(event) когда игрок касается объекта - * game.self.move(x, y, z) — переместить объект (для моделей/примитивов) - * game.self.delete() — удалить объект-носитель - */ - -const SOURCE = ` -"use strict"; - -// === Внутреннее состояние Worker'а === -let _tickHandlers = []; -let _playerState = { - position: { x: 0, y: 0, z: 0 }, - yaw: 0, - pitch: 0, - forward: { x: 0, y: 0, z: 1 }, // нормализованный вектор взгляда - crosshair: 'none', // 'none' | 'dot' | 'cross' | 'circle' - hp: 100, - maxHp: 100, - state: 'ground', // 'ground' | 'air' | 'water' - keys: {}, // { 'w': true, 'space': true } — зажатые сейчас клавиши -}; -// target скрипта (если есть) — пришёл при init -let _target = null; -// Зеркало position объекта-носителя (если target.kind != null) -let _selfPosition = { x: 0, y: 0, z: 0 }; -// Снимок живых мобов — обновляется каждый tick из main thread -let _mobs = []; -// Снимок NPC (Фаза 4.1) — обновляется каждый tick из main thread. -// Каждый: { id, name, x, y, z, hp, maxHp, mode }. -let _npcs = []; -// Счётчик локальных ref'ов для NPC, заспавненных скриптом. -let _npcRefSeq = 0; -// Маппинг локальный ref ('npc:_local_N') → реальный числовой npcId. -// Заполняется когда main thread присылает 'npcSpawned' после async-спавна. -let _npcLocalToReal = {}; -// Маппинг локальный ref scene.spawn ('primitive:_local_N') → реальный -// ('primitive:N'). main thread шлёт 'spawnResolved' после создания. -// Нужно чтобы getPosition и др. находили заспавненный объект в _sceneIndex. -let _spawnLocalToReal = {}; -// Подписки npc.onDeath: ключ = локальный ref ИЛИ строка-id → [fn]. -let _npcDeathHandlers = {}; -// Глобальные подписки game.onNpcDeath(fn). -let _globalNpcDeathHandlers = []; -// Снимок инвентаря (Фаза 4.2): { slots: [...], activeIndex }. -let _inventory = { slots: [], activeIndex: 0 }; -// Подписки game.player.onToolUse(fn). -let _toolUseHandlers = []; -// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. -let _players = { me: null, list: [] }; -// Общее состояние комнаты game.room.get/set — зеркало из main thread. -let _roomState = {}; -// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). -let _playerJoinHandlers = []; -let _playerLeaveHandlers = []; -// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). -let _cutsceneDoneHandlers = []; -let _mpMessageHandlers = {}; // name → [fn] -// Подписки game.room.onChange(key, fn): key → [fn]. -let _roomChangeHandlers = {}; -// Команды (Фаза 4.4): массив { name, color } — зеркало из main thread. -let _teams = []; -// Счётчик локальных ref'ов для связей-constraints (Фаза 5). -let _constraintRefSeq = 0; -// Счётчик локальных ref'ов для лучей/следов (Фаза 5.2). -let _fxRefSeq = 0; -// Счётчик локальных ref'ов для звуков (Фаза 5.5). -let _soundRefSeq = 0; -// Подписки на события объекта (self.*) -let _selfClickHandlers = []; -let _selfTouchHandlers = []; -let _selfUntouchHandlers = []; -// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) -let _selfInteractHandlers = []; -// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). -// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих -// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. -const _instTouchHandlers = new Map(); -function _instHandlerBucket(ref) { - let b = _instTouchHandlers.get(ref); - if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } - return b; -} -// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') -let _guiIndex = []; -// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). -// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. -// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. -let _skinsIndex = []; -let _unlockedSkins = []; -let _currentSkin = null; -let _skinChangeHandlers = []; -let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) -// Подписки game.gui.onClick(id, fn) -let _guiClickHandlers = {}; -// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) -let _guiSubmitHandlers = {}; -// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке -// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка -// из game.scene.spawn() или game.scene.findOne() в формате -// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime), -// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. -let _billboardClickHandlers = {}; -// Для 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 + имя элемента (скрипт -// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). -function _guiHandlerKeys(id, localId) { - const keys = [id]; - // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог - // подписаться по нему, если не задавал явный id. - if (localId != null && localId !== id) keys.push(localId); - // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). - const el = _guiIndex.find(g => g.id === id); - if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); - return keys; -} - -// Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с -// числовыми id, локальный ref → id через _npcLocalToReal. -function _findNpcState(localRef) { - const id = _npcLocalToReal[localRef]; - if (id == null) return null; - return _npcs.find(n => n.id === id) || null; -} - -// Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref — -// main thread резолвит его в реальный npcId. -function _makeNpcProxy(ref) { - return { - get ref() { return ref; }, - /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */ - get position() { - const st = _findNpcState(ref); - return st ? { x: st.x, y: st.y, z: st.z } : null; - }, - /** Текущее HP или null. */ - get hp() { - const st = _findNpcState(ref); - return st ? st.hp : null; - }, - /** Имя NPC или null. */ - get name() { - const st = _findNpcState(ref); - return st ? st.name : null; - }, - /** Идти в точку (XZ). */ - moveTo(x, z) { - _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 }); - }, - /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход - * в кат-сцене → быстрая погоня в игре. */ - setSpeed(speed) { - const s = Number(speed); - if (Number.isFinite(s) && s > 0) { - _send('npc.setSpeed', { ref, speed: s }); - } - }, - /** Следовать за объектом: 'player' или ref объекта сцены. */ - follow(target) { - _send('npc.follow', { ref, target }); - }, - /** Остановиться. */ - stop() { - _send('npc.stop', { ref }); - }, - /** Реплика над головой на duration секунд (по умолчанию 3). */ - say(text, duration) { - _send('npc.say', { - ref, - text: String(text == null ? '' : text), - duration: Number.isFinite(Number(duration)) ? Number(duration) : 3, - }); - }, - /** Нанести урон NPC. */ - damage(amount) { - _send('npc.damage', { ref, amount: Number(amount) || 0 }); - }, - /** Убрать NPC со сцены. */ - remove() { - _send('npc.remove', { ref }); - }, - /** Колбэк при гибели этого NPC. fn получает {id, position}. */ - onDeath(fn) { - if (typeof fn === 'function') { - (_npcDeathHandlers[ref] = _npcDeathHandlers[ref] || []).push(fn); - } - }, - }; -} -// Глобальные подписки -let _globalKeyDownHandlers = {}; // { 'w': [fn, fn], ... } — ключи нормализованы в lower-case -let _globalKeyUpHandlers = {}; -let _globalClickHandlers = []; -let _globalTouchHandlers = []; -// Колбэки на движение мыши в UI-режиме (game.input.onMouseMove) -let _mouseMoveHandlers = []; -let _mouseDownHandlers = []; -let _mouseUpHandlers = []; -// Колбэк на убийство моба (зомби и т.п.) — fn({mobType, position}) -let _mobKilledHandlers = []; - -// SaveGame API: счётчик request-id и map колбэков по reqId -let _saveReqSeq = 0; -const _saveCallbacks = {}; -// Economy API (GD-награды): request-id → callback -let _economyReqSeq = 0; -const _economyCallbacks = {}; -// Колбэк на изменение HP игрока (для логирования урона/смерти из скриптов) -let _hpChangeHandlers = []; -// Подписки на события игрока: смерть / прыжок / приземление -let _playerDiedHandlers = []; -let _playerJumpHandlers = []; -let _playerLandHandlers = []; -// Broadcast между sandbox'ами: имя сообщения → массив обработчиков -let _messageHandlers = {}; -// Счётчик для локальных ref'ов спавненных через game.scene.spawn -let _localRefSeq = 0; - -// Твины (game.tween): id → callback onDone. Сами твины крутит main-thread -// (GameRuntime), сюда возвращается только событие завершения по reqId. -let _tweenSeq = 0; -const _tweenCallbacks = {}; - -// Таймеры (game.after / game.every). Каждый: { id, fn, delay, elapsed, repeat } -// repeat=false → after (один раз), repeat=true → every (циклично). -// Тикаются в обработчике cmd='tick' по накоплению dt. -let _timers = []; -let _timerSeq = 0; -// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз -// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. -// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } -let _sceneIndex = { blocks: [], models: [], primitives: [] }; - -// Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). -// Приходит один раз через cmd='terrainHeightmap'. Формат: -// { origin:{x,z}, step, cols, rows, heights:number[] } -let _terrainHM = null; - -// Атрибуты объектов (game.scene.setData/getData). Зеркало из main thread. -// Формат: { ref: { key: value, ... } }. Синхронизируется через cmd='dataSnapshot'. -// getData читает отсюда синхронно, setData шлёт команду в main. -let _dataIndex = {}; - -// ═══════════════════════════════════════════════════════════════════════ -// Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают -// Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, -// position/name/parent/children и т.д.). Coerces в строку-ref через -// Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и -// т.п., принимавший строковый ref) продолжает работать без изменений. -const _instCache = new Map(); // ref → Instance proxy -const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } -const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot - -function _safeCall2(fn, args, where) { - try { fn.apply(null, args); } - catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), - }); - } -} - -function _getOrCreateInstance(ref, kindHint) { - if (!ref || typeof ref !== 'string') return null; - if (_instCache.has(ref)) return _instCache.get(ref); - - const target = { - get ref() { return ref; }, - get kind() { - if (kindHint) return kindHint; - for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; - for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; - for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; - return 'unknown'; - }, - toString() { return ref; }, - }; - - const proxy = new Proxy(target, { - get(t, prop) { - if (prop === 'ref' || prop === 'kind') return t[prop]; - if (prop === 'toString') return t.toString; - if (prop === Symbol.toPrimitive) return () => ref; - if (prop === 'valueOf') return () => ref; - - // === Геттеры из snapshot === - if (prop === 'position') { - for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; - } - return null; - } - if (prop === 'name') { - for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) if (o.ref === ref) return o.name || null; - } - return null; - } - if (prop === 'parent') { - const data = _dataIndex[ref]; - const parentRef = data && data.__parent; - return parentRef ? _getOrCreateInstance(parentRef) : null; - } - if (prop === 'children') { - const data = _dataIndex[ref]; - const ids = (data && data.__children) || []; - return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); - } - if (prop === 'descendants') { - return () => { - const out = []; - const stack = [...((_dataIndex[ref] || {}).__children || [])]; - while (stack.length) { - const id = stack.pop(); - const inst = _getOrCreateInstance(id); - if (inst) { - out.push(inst); - const kids = (_dataIndex[id] || {}).__children || []; - for (const k of kids) stack.push(k); - } - } - return out; - }; - } - - // === События === - if (prop === 'changed') { - return { - connect(propName, fn) { - if (typeof fn !== 'function') return; - let evs = _instEvents.get(ref); - if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } - evs.propChanged.push({ prop: propName, fn }); - }, - }; - } - if (prop === 'destroying') { - return { - connect(fn) { - if (typeof fn !== 'function') return; - let evs = _instEvents.get(ref); - if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } - evs.destroying.push(fn); - }, - }; - } - - // === Методы === - if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); - if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); - if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); - if (prop === 'getAttribute') return (k) => { - const data = _dataIndex[ref]; - return data ? data[k] : undefined; - }; - if (prop === 'hasTag') return (t) => { - const data = _dataIndex[ref]; - return Array.isArray(data && data.__tags) && data.__tags.includes(t); - }; - if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); - if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); - if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); - if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); - if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); - if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); - - // === События касания/клика ПРОИЗВОЛЬНОГО объекта === - // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. - // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB - // объекта (inst.watchTouch) и шлёт instTouch на rising edge. - if (prop === 'onTouch') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).touch.push(fn); - _send('inst.watchTouch', { ref }); - }; - if (prop === 'onUntouch') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).untouch.push(fn); - _send('inst.watchTouch', { ref }); - }; - if (prop === 'onClick') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).click.push(fn); - _send('inst.watchClick', { ref }); - }; - - return undefined; - }, - set(t, prop, value) { - if (prop === 'name') { - _send('inst.set', { ref, prop: 'name', value: String(value) }); - return true; - } - if (prop === 'parent') { - const parentRef = value && typeof value === 'object' - ? (value.ref || value.toString()) - : (value || null); - _send('inst.setParent', { ref, parentRef }); - return true; - } - if (prop === 'position') { - if (value && typeof value === 'object') { - const x = Number(value.x), y = Number(value.y), z = Number(value.z); - if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { - _send('scene.move', { ref, x, y, z }); - } - } - return true; - } - if (prop === 'color') { - _send('scene.setColor', { ref, color: String(value) }); - return true; - } - if (prop === 'transparency' || prop === 'opacity') { - const v = Number(value); - if (Number.isFinite(v)) { - const op = prop === 'transparency' ? (1 - v) : v; - _send('scene.setOpacity', { ref, value: op }); - } - return true; - } - if (prop === 'visible') { - _send('scene.setVisible', { ref, visible: !!value }); - return true; - } - if (prop === 'canCollide') { - _send('scene.setCollide', { ref, collide: !!value }); - return true; - } - if (prop === 'material') { - _send('scene.setMaterial', { ref, name: String(value) }); - return true; - } - return true; - }, - }); - - _instCache.set(ref, proxy); - return proxy; -} - -/** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ -function _emitInstChange(ref, prop, newVal, oldVal) { - const evs = _instEvents.get(ref); - if (!evs) return; - for (const rec of evs.propChanged) { - if (rec.prop === prop) { - _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); - } - } -} - -/** Триггер destroying — объект больше не в snapshot. */ -function _emitInstDestroying(ref) { - const evs = _instEvents.get(ref); - if (evs) { - for (const fn of evs.destroying) { - _safeCall2(fn, [], 'inst.destroying'); - } - } - _instEvents.delete(ref); - _instCache.delete(ref); - _instLastValues.delete(ref); -} - -/** - * Сравнение нового snapshot со старым — детект дельт для событий. - * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. - */ -function _detectSnapshotDeltas() { - const live = new Set(); - for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) { - live.add(o.ref); - if (!_instCache.has(o.ref)) continue; - const prev = _instLastValues.get(o.ref) || {}; - if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { - _emitInstChange(o.ref, 'position', - { x: o.x, y: o.y, z: o.z }, - { x: prev.x, y: prev.y, z: prev.z }); - } - if (prev.name !== undefined && prev.name !== o.name) { - _emitInstChange(o.ref, 'name', o.name, prev.name); - } - _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); - } - } - for (const ref of [..._instCache.keys()]) { - if (live.has(ref)) continue; - const inst = _instCache.get(ref); - if (inst && inst.kind === 'primitive') { - _emitInstDestroying(ref); - } - } -} - -// Модули (game.require). Код всех скриптов-модулей приходит при init. -// _moduleCode — { 'имя': 'код модуля' } -// _moduleCache — { 'имя': exports } — кеш исполненных модулей -let _moduleCode = {}; -let _moduleCache = {}; - -// Утилиты безопасной отправки в main -const _send = (cmd, payload) => { - try { postMessage({ cmd, payload }); } catch (e) {} -}; - -// Нормализация ref: строка → она сама; Instance-прокси → поле .ref; -// иначе null. Нужно чтобы billboard.set/update/onClick принимали и -// строковый ref ('primitive:NN'), и объект, у которого есть .ref. -function _normRef(ref) { - if (typeof ref === 'string') return ref || null; - if (ref && typeof ref === 'object') { - if (typeof ref.ref === 'string' && ref.ref) return ref.ref; - const s = String(ref); - return s && s !== '[object Object]' ? s : null; - } - return null; -} - -const _safeCall = (fn, arg, where) => { - try { fn(arg); } - catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), - }); - } -}; - -// Внутренний хелпер: запланировать удаление объекта через seconds секунд. -// Используется для lifetime в scene.spawn и для scene.deleteAfter. -const _scheduleDelete = (ref, seconds) => { - const s = Number(seconds); - if (typeof ref !== 'string' || !ref || !Number.isFinite(s) || s < 0) return; - _timers.push({ - id: ++_timerSeq, - fn: () => _send('scene.delete', { ref }), - delay: s, elapsed: 0, repeat: false, - }); -}; - -// === Публичное API game.* === - -// Объект self создаётся ПОСЛЕ получения init с target. -// Если target нет (глобальный скрипт), game.self === null. -let _selfApi = null; - -function _buildSelfApi() { - if (!_target) return null; - const isGui = _target.kind === 'gui'; - const api = { - get kind() { return _target.kind; }, - // ref — строка формата 'primitive:N' / 'model:N' / 'block:x,y,z', - // единая со scene.find/all и пригодная для scene.* / physics.* / tween. - get ref() { - const k = _target.kind; - if (k === 'primitive' || k === 'model' || k === 'userModel') { - const id = _target.id ?? _target.ref; - return id != null ? k + ':' + id : null; - } - if (k === 'block') { - const r = _target.ref || _target; - if (r && r.x != null) return 'block:' + r.x + ',' + r.y + ',' + r.z; - return null; - } - return _target.ref ?? _target.id ?? null; - }, - get position() { return { ..._selfPosition }; }, - /** - * Свойства GUI-элемента (только для скриптов с target.kind='gui'): - * game.self.props — текущие свойства (имя, текст, x/y/w/h, цвет...) - */ - get props() { - if (!isGui) return null; - const id = _target.id ?? _target.ref; - const found = _guiIndex.find(g => g.id === id); - return found ? { ...found } : null; - }, - onClick(fn) { - if (typeof fn === 'function') _selfClickHandlers.push(fn); - }, - onTouch(fn) { - if (typeof fn === 'function') _selfTouchHandlers.push(fn); - }, - /** Игрок ВЫШЕЛ из объекта (был внутри AABB и вышел). Полезно для триггер-зон. */ - onUntouch(fn) { - if (typeof fn === 'function') _selfUntouchHandlers.push(fn); - }, - /** - * Взаимодействие по клавише E. Когда игрок подходит близко к объекту — - * над объектом появляется подсказка «[E] ...», по нажатию E срабатывает fn. - * game.self.onInteract(() => { - * game.ui.showText('Дверь открыта!'); - * }, { text: 'Открыть дверь', distance: 4 }); - * opts: { text: 'Взаимодействовать', distance: 4 (метры), key: 'e' }. - */ - onInteract(fn, opts) { - if (typeof fn !== 'function') return; - _selfInteractHandlers.push(fn); - // регистрируем объект как интерактивный — main покажет подсказку - _send('self.registerInteract', { - target: _target, - text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', - distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, - key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', - }); - }, - move(x, y, z) { - const nx = Number(x), ny = Number(y), nz = Number(z); - if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { - _send('self.move', { target: _target, x: nx, y: ny, z: nz }); - } - }, - delete() { - _send('self.delete', { target: _target }); - }, - /** - * Изменить свойства GUI-элемента (только для target.kind='gui'). - * game.self.update({ text: 'Новый текст', textColor: '#ff0000' }); - */ - update(patch) { - if (!isGui || !patch || typeof patch !== 'object') return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch }); - }, - /** Шорткат для смены текста (для Text/Button). */ - setText(text) { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { text: String(text == null ? '' : text) } }); - }, - /** Сделать элемент видимым. */ - show() { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { visible: true } }); - }, - /** Скрыть элемент. */ - hide() { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { visible: false } }); - }, - }; - return api; -} - -const game = { - player: { - /** Позиция «низа ног» игрока. */ - get position() { return { ..._playerState.position }; }, - /** - * Угол поворота игрока вокруг Y (в радианах). - * 0 = смотрит в +Z, π/2 = +X, π = -Z, -π/2 = -X. - */ - get yaw() { return _playerState.yaw || 0; }, - /** Наклон вверх/вниз (в радианах). >0 = смотрит вверх. */ - get pitch() { return _playerState.pitch || 0; }, - /** - * Нормализованный вектор взгляда (направление куда смотрит игрок). - * Удобно использовать для спавна объектов «перед собой»: - * const f = game.player.forward; - * game.scene.spawn('block:grass', { x: p.x + f.x*3, y: p.y, z: p.z + f.z*3 }); - */ - get forward() { return { ..._playerState.forward }; }, - /** - * Команда локального игрока (Фаза 4.4) — имя команды или null. - * Назначается через game.player.setTeam('Красные'). - */ - get team() { - return (_players.me && _players.me.team) || null; - }, - /** - * Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. - * Чтение возвращает текущее значение, запись — меняет в рантайме: - * game.player.crosshair = 'cross'; - */ - get crosshair() { return _playerState.crosshair || 'none'; }, - set crosshair(v) { - const allowed = ['none', 'dot', 'cross', 'circle']; - const s = String(v || 'none').toLowerCase(); - if (!allowed.includes(s)) return; - _playerState.crosshair = s; - _send('player.crosshair', { type: s }); - }, - teleport(x, y, z) { - const nx = Number(x), ny = Number(y), nz = Number(z); - if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { - _send('player.teleport', { x: nx, y: ny, z: nz }); - } - }, - /** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров — - * смена полосы без отмены продвижения autorun. */ - setLaneX(x) { - const nx = Number(x); - if (Number.isFinite(nx)) _send('player.setLaneX', { x: nx }); - }, - /** Развернуть модель игрока на угол yaw (радианы). Для кат-сцен, - * где игрок стоит лицом в нужную сторону. yaw=0 — лицом в +Z. */ - setFacing(yaw) { - const y = Number(yaw); - if (Number.isFinite(y)) _send('player.setFacing', { yaw: y }); - }, - /** Проиграть эмоцию персонажа: 'wave'|'dance'|'cheer'|'sit'|'paint'. - * Работает только для R15-скинов. Разовая анимация поверх движения. */ - playEmote(name) { - if (typeof name === 'string') _send('player.emote', { name }); - }, - /** Прервать текущую эмоцию персонажа. */ - stopEmote() { - _send('player.stopEmote', {}); - }, - /** Текущее HP игрока (зеркало из main thread). */ - get hp() { return _playerState.hp ?? 100; }, - get maxHp() { return _playerState.maxHp ?? 100; }, - /** Текущее направление гравитации в Кубикон Dash (+1 вниз, -1 вверх). */ - get gravityDir() { return _playerState.gravityDir ?? 1; }, - /** Жив ли игрок (hp > 0). */ - get alive() { return (_playerState.hp ?? 100) > 0; }, - /** - * Состояние игрока: 'ground' (на земле), 'air' (в воздухе/прыжке), - * 'water' (в воде). - * if (game.player.state === 'air') { ... } - */ - get state() { return _playerState.state || 'ground'; }, - /** - * Зажата ли клавиша ПРЯМО СЕЙЧАС (для плавного движения по удержанию). - * key — 'w','a','s','d','space','shift','arrowup'... (lowercase). - * game.onTick(() => { if (game.player.isKeyDown('w')) { ... } }); - */ - isKeyDown(key) { - if (typeof key !== 'string') return false; - return !!_playerState.keys[key.toLowerCase()]; - }, - /** - * Нанести урон игроку. Учитываются i-frames (повторный вызов - * в течение ~0.5с проигнорится). - */ - damage(amount) { - const a = Number(amount); - if (!Number.isFinite(a) || a <= 0) return; - _send('player.damage', { amount: a }); - }, - /** Мгновенно убить игрока (игнорит i-frames). */ - kill() { - _send('player.damage', { amount: 99999 }); - }, - /** Восстановить здоровье. */ - heal(amount) { - const a = Number(amount); - if (!Number.isFinite(a) || a <= 0) return; - _send('player.heal', { amount: a }); - }, - /** - * Вернуть игрока на spawn-point с полным HP. - * Если spawnPoint в проекте задан — телепортирует туда. - */ - respawn() { - _send('player.respawn', null); - }, - /** - * Множитель скорости передвижения. 1 = норма, 1.5 = +50%, 0.5 = вдвое медленнее. - */ - setSpeed(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setSpeed', { mul: m }); - }, - /** - * Множитель силы прыжка. 1 = норма, 1.5 = выше, 2 = ещё выше. - */ - setJumpPower(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setJumpPower', { mul: m }); - }, - /** - * Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md. - * Надеть на игрока аксессуар (шляпа/инструмент/причёска/лицо) из - * каталога Рублокса. itemId — числовой id из rublox_items - * (только published — драфты дизайнеров не видны). - * Пример: game.player.equipAccessory(42); // надеть шляпу id=42 - */ - equipAccessory(itemId) { - const id = Number(itemId); - if (Number.isFinite(id) && id > 0) { - _send('player.equipAccessory', { itemId: id }); - } - }, - /** - * Снять аксессуар из слота: 'hat'|'tool'|'tool_left'|'hair'|'face'. - * Пример: game.player.unequipSlot('hat'); - */ - unequipSlot(slot) { - const s = String(slot || '').trim(); - if (s) _send('player.unequipSlot', { slot: s }); - }, - /** Снять все аксессуары. */ - unequipAll() { - _send('player.unequipAll', {}); - }, - /** - * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²). - * Работает в обоих направлениях gravityDir. - */ - setGravityMul(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m }); - }, - /** - * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). - * Обычный jump-импульс отключается, корабль управляется только Space. - */ - setShipMode(enabled) { - _send('player.setShipMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе - * (даже без касания земли). Обычный прыжок отключается. - */ - setUfoMode(enabled) { - _send('player.setUfoMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод Wave: движение жёстко под ±45°. - * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed. - * Гравитация и прыжок отключены. - */ - setWaveMode(enabled) { - _send('player.setWaveMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space. - * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли. - */ - setRobotMode(enabled) { - _send('player.setRobotMode', { enabled: !!enabled }); - }, - /** - * Двойной прыжок (true/false) — второй прыжок в воздухе. - */ - setDoubleJump(enabled) { - _send('player.setDoubleJump', { enabled: !!enabled }); - }, - /** - * Проиграть эмоцию-анимацию персонажа один раз. - * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть). - * game.onKey('e', () => game.player.playAnimation('wave')); - */ - playAnimation(name) { - if (typeof name !== 'string') return; - _send('player.playAnimation', { name }); - }, - /** Прервать текущую эмоцию персонажа. */ - stopAnimation() { - _send('player.stopAnimation', {}); - }, - /** - * Скользкость поверхности под игроком. 0 = нормальное движение - * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания - * клавиш). 1 = полностью скользко (инерция бесконечная). - */ - setIceFriction(value) { - const v = Number(value); - if (Number.isFinite(v)) { - _send('player.setIceFriction', { value: v }); - } - }, - /** - * Кубикон Dash: авто-бег по +X со скоростью speed (м/с). - * Передай 0 чтобы отключить. Работает только в sideview-камере — - * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview'). - * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview'); - */ - setAutoRun(speed) { - const s = Number(speed); - if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s }); - }, - /** - * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок, - * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу. - * Используется для трамплинов в Кубикон Dash. - */ - boostJump(strength) { - const s = Number(strength); - if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s }); - }, - /** - * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD). - * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает. - * Доступно только в sideview-режиме. - */ - flipGravity() { - _send('player.flipGravity', {}); - }, - /** - * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз. - * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д. - * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy. - */ - setVy(vy) { - const v = Number(vy); - if (Number.isFinite(v)) _send('player.setVy', { vy: v }); - }, - /** - * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх. - */ - setGravityDir(dir) { - const d = Number(dir); - if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d }); - }, - /** - * Показать/скрыть основной скин игрока. Используется в Кубикон Dash: - * скрываем человечка, рисуем куб-примитив через скрипт. - */ - setSkinVisible(visible) { - _send('player.setSkinVisible', { visible: !!visible }); - }, - /** - * === Задача 07: скины игрока (любая 3D-модель + магазин) === - * Сменить активный скин в Play (без перезагрузки сцены). - * game.player.setSkin('squirrel-donut'); // встроенный - * game.player.setSkin('character-a'); // человек - * Возвращает «локальный Promise» (объект с .then) — реальная смена - * асинхронна (грузится .glb). Для большинства игр можно не ждать. - */ - setSkin(slug) { - if (typeof slug !== 'string' || !slug) return; - _currentSkin = slug; - if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - _send('player.setSkin', { slug }); - }, - /** Дать игроку скин (разблокировать — например после покупки). */ - unlockSkin(slug) { - if (typeof slug !== 'string' || !slug) return; - if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - _send('player.unlockSkin', { slug }); - }, - /** Список slug'ов скинов, доступных игроку (разблокированных). */ - getAvailableSkins() { - return _unlockedSkins.slice(); - }, - /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ - getAllSkins() { - return _skinsIndex.map(s => ({ ...s })); - }, - /** Текущий активный скин (slug). */ - getCurrentSkin() { - return _currentSkin; - }, - /** Подписка на смену скина: fn(slug). */ - onSkinChange(fn) { - if (typeof fn === 'function') _skinChangeHandlers.push(fn); - }, - /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ - openSkinShop() { - _send('player.openSkinShop', {}); - }, - /** Закрыть магазин скинов. */ - closeSkinShop() { - _send('player.closeSkinShop', {}); - }, - /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — - * не путать с серверной экономикой game.economy). */ - getSkinCoins() { - return _skinCoins; - }, - /** Задать баланс валюты магазина (например стартовые 200). */ - setSkinCoins(amount) { - const n = Number(amount); - if (!Number.isFinite(n)) return; - _skinCoins = Math.max(0, Math.floor(n)); - _send('player.setSkinCoins', { amount: _skinCoins }); - }, - /** Добавить валюту магазина (награда за что-то). */ - addSkinCoins(amount) { - const n = Number(amount); - if (!Number.isFinite(n)) return; - _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); - _send('player.setSkinCoins', { amount: _skinCoins }); - }, - /** - * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. - * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, - * yaw/pitch от мыши/тача игнорируются. - */ - setCameraMode(mode) { - if (typeof mode !== 'string') return; - _send('player.setCameraMode', { mode }); - }, - /** Задача 02: установить дистанцию камеры (для third-person). */ - setCameraZoom(distance) { - const d = Number(distance); - if (!Number.isFinite(d)) return; - _send('player.setCameraZoom', { distance: d }); - }, - /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ - setCameraZoomLimits(min, max) { - const mn = Number(min), mx = Number(max); - if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; - _send('player.setCameraZoomLimits', { min: mn, max: mx }); - }, - /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ - setShiftLock(on) { - _send('player.setShiftLock', { on: !!on }); - }, - /** - * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. - * Используется чтобы пройти под низким потолком. - */ - setCrouch(enabled) { - _send('player.setCrouch', { enabled: !!enabled }); - }, - /** - * Назначить активную точку возрождения. При respawn / смерти - * игрок появится здесь. Аргумент: - * - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') — - * игрок встанет НАД этим объектом; - * - объект {x, y, z} — точные координаты. - * game.self.onInteract(() => game.player.setSpawn(game.self.ref)); - */ - setSpawn(target) { - if (typeof target === 'string' && target) { - _send('player.setSpawn', { ref: target }); - } else if (target && typeof target === 'object' - && Number.isFinite(Number(target.x))) { - _send('player.setSpawn', { - x: Number(target.x), - y: Number(target.y), - z: Number(target.z), - }); - } - }, - /** - * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2). - * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...) - * или произвольное имя предмета. - * opts: { name, equip:true (сразу взять в руки), params }. - * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры - * боя; прочее — kind='tool'. - * game.player.giveTool('blaster-blaster-a', { equip: true }); - */ - giveTool(toolType, opts) { - if (typeof toolType !== 'string' || !toolType) return; - opts = opts || {}; - const isBlaster = toolType.indexOf('blaster') === 0; - const isMelee = toolType.indexOf('weapon-') === 0; - let kind = 'tool'; - let params = {}; - if (isBlaster) { - kind = 'weapon'; - params = { - damage: 25, fireRate: 0.2, range: 60, - magazine: 12, reserve: 48, - }; - } else if (isMelee) { - kind = 'weapon'; - params = { weaponKind: 'melee', damage: 35, fireRate: 0.6, range: 3 }; - } - // opts.params переопределяет дефолты. - if (opts.params && typeof opts.params === 'object') { - params = { ...params, ...opts.params }; - } - _send('inventory.give', { - kind, - modelTypeId: toolType, - name: typeof opts.name === 'string' ? opts.name : toolType, - params, - equip: opts.equip === true, - }); - }, - /** Убрать инструмент/оружие из инвентаря по id модели или имени. */ - removeTool(toolType) { - if (typeof toolType !== 'string') return; - _send('inventory.remove', { modelTypeId: toolType, name: toolType }); - }, - /** - * Подписка: игрок применил инструмент (ЛКМ с предметом в активном - * слоте). fn получает { tool: {kind, modelTypeId, name}, point, target }. - */ - onToolUse(fn) { - if (typeof fn === 'function') _toolUseHandlers.push(fn); - }, - /** - * Назначить игроку команду (Фаза 4.4). Команда должна быть - * заранее создана через game.teams.create(). null/'' убирает. - * game.teams.create('Красные', '#ff3333'); - * game.player.setTeam('Красные'); - */ - setTeam(name) { - _send('player.setTeam', { team: typeof name === 'string' ? name : null }); - }, - }, - /** - * Таймер прохождения для лидерборда. - * game.timer.start() — запустить отсчёт (с нуля, отображается в HUD). - * game.timer.stop() — остановить (но не отправлять). - * game.timer.submit() — остановить + отправить рекорд в лидерборд. - * Сервер сохраняет если время лучше предыдущего. - */ - timer: { - start() { _send('timer.start', null); }, - stop() { _send('timer.stop', null); }, - submit() { _send('timer.submit', null); }, - }, - get self() { return _selfApi; }, - onTick(fn) { - if (typeof fn === 'function') _tickHandlers.push(fn); - }, - /** - * Выполнить fn ОДИН раз через seconds секунд. - * Возвращает id таймера — его можно отменить через game.cancel(id). - * game.after(3, () => game.ui.showText('Прошло 3 секунды!')); - */ - after(seconds, fn) { - const s = Number(seconds); - if (!Number.isFinite(s) || s < 0 || typeof fn !== 'function') return null; - const id = ++_timerSeq; - _timers.push({ id, fn, delay: s, elapsed: 0, repeat: false }); - return id; - }, - /** - * Выполнять fn КАЖДЫЕ seconds секунд (циклично). - * Возвращает id таймера — остановить через game.cancel(id). - * const t = game.every(1, () => game.log('тик')); - * game.after(10, () => game.cancel(t)); // через 10с остановить - */ - every(seconds, fn) { - const s = Number(seconds); - if (!Number.isFinite(s) || s <= 0 || typeof fn !== 'function') return null; - const id = ++_timerSeq; - _timers.push({ id, fn, delay: s, elapsed: 0, repeat: true }); - return id; - }, - /** Отменить таймер (after или every) по id, который вернул after()/every(). */ - cancel(id) { - if (id == null) return; - const i = _timers.findIndex(t => t.id === id); - if (i >= 0) _timers.splice(i, 1); - }, - /** - * Плавно изменить свойства объекта (твин — анимация перехода). - * ref — объект сцены (то что вернул scene.spawn / scene.find) или GUI-id. - * props — что менять и до какого значения: - * { x, y, z, rotationX, rotationY, rotationZ, sx, sy, sz, - * color: '#ff0000', opacity: 0..1 } - * opts — { duration: 1 (сек), easing: 'linear'|'ease'|'bounce'|'elastic'|'back', - * delay: 0, repeat: 0 (раз; -1 = бесконечно), yoyo: false, - * onDone: fn } - * Возвращает tweenId — анимацию можно прервать через game.cancelTween(id). - * - * // плавно открыть дверь за 1 секунду - * game.tween(door, { rotationY: Math.PI/2 }, { duration: 1, easing: 'ease' }); - * // пульсирующая монетка - * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); - */ - tween(ref, props, opts) { - if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; - opts = opts || {}; - const id = ++_tweenSeq; - if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; - _send('tween.start', { - tweenId: id, - ref, - props, - duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 1, - easing: typeof opts.easing === 'string' ? opts.easing : 'ease', - delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, - repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, - yoyo: !!opts.yoyo, - }); - return id; - }, - /** Прервать твин по id, который вернул game.tween(). */ - cancelTween(id) { - if (id == null) return; - delete _tweenCallbacks[id]; - _send('tween.cancel', { tweenId: id }); - }, - /** - * Подписаться на нажатие клавиши. - * key — буква 'w', 'a', 's', 'd' или специальные имена 'space', 'shift', - * 'enter', 'escape', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'. - * Сравнение case-insensitive. Если key не передан — fn вызывается на любую клавишу. - */ - onKey(key, fn) { - if (typeof key === 'function') { fn = key; key = '*'; } - if (typeof fn !== 'function') return; - const k = String(key).toLowerCase(); - (_globalKeyDownHandlers[k] = _globalKeyDownHandlers[k] || []).push(fn); - }, - /** То же что onKey, но на отпускание клавиши. */ - onKeyUp(key, fn) { - if (typeof key === 'function') { fn = key; key = '*'; } - if (typeof fn !== 'function') return; - const k = String(key).toLowerCase(); - (_globalKeyUpHandlers[k] = _globalKeyUpHandlers[k] || []).push(fn); - }, - /** - * Глобальный клик в Play-режиме. event = {point, target}. - * target = null если клик прошёл мимо объектов. - */ - onClick(fn) { - if (typeof fn === 'function') _globalClickHandlers.push(fn); - }, - /** - * Игрок коснулся любого объекта (с target-скриптом или без — - * для глобального события событие шлётся ВСЕГДА). - * event = {target}. - */ - onPlayerTouch(fn) { - if (typeof fn === 'function') _globalTouchHandlers.push(fn); - }, - /** - * Моб убит игроком (или другим способом). - * fn({mobType, position}). mobType: 'zombie' | ... - */ - onMobKilled(fn) { - if (typeof fn === 'function') _mobKilledHandlers.push(fn); - }, - /** - * Любой NPC погиб (hp дошёл до 0). fn({id, position}). - * Для конкретного NPC удобнее npc.onDeath(fn) на объекте-NPC. - */ - onNpcDeath(fn) { - if (typeof fn === 'function') _globalNpcDeathHandlers.push(fn); - }, - /** - * Игрок присоединился к комнате (Фаза 4.3). fn({sessionId, name}). - */ - onPlayerJoin(fn) { - if (typeof fn === 'function') _playerJoinHandlers.push(fn); - }, - /** - * Катсцена камеры доиграла (Фаза 5.7). fn() — без аргументов. - * game.camera.cutscene([...]); - * game.onCutsceneDone(() => game.camera.reset()); - */ - onCutsceneDone(fn) { - if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); - }, - /** Игрок покинул комнату. fn({sessionId, name}). */ - onPlayerLeave(fn) { - if (typeof fn === 'function') _playerLeaveHandlers.push(fn); - }, - /** - * Подписаться на адресное сообщение (Фаза 4.3). fn({from, data}). - * game.onMessage('подарок', (msg) => game.ui.showText('от ' + msg.from)); - */ - onMessage(name, fn) { - if (typeof name !== 'string' || typeof fn !== 'function') return; - (_mpMessageHandlers[name] = _mpMessageHandlers[name] || []).push(fn); - }, - /** - * Отправить сообщение игроку (Фаза 4.3). - * game.sendTo(player, 'подарок', { gold: 100 }); - * player — объект из game.players.* или его sessionId. - */ - sendTo(player, name, data) { - if (typeof name !== 'string') return; - const sessionId = typeof player === 'string' - ? player - : (player && player.sessionId); - if (!sessionId) return; - _send('mp.sendTo', { sessionId, name, data }); - }, - /** - * Подписаться на изменение HP игрока (получение урона / лечение / смерть). - * fn(event) где event = { hp, maxHp, source, damaged, delta }. - * - source — строка ('script', 'zombie', 'fall', 'lava', ...) или null. - * - delta — изменение HP (отрицательное = урон, положительное = лечение). - * - damaged — true если это был урон. - */ - onHpChange(fn) { - if (typeof fn === 'function') _hpChangeHandlers.push(fn); - }, - /** - * Игрок погиб (hp дошло до 0). Срабатывает один раз на смерть. - * game.onPlayerDied(() => game.ui.showText('Игра окончена', 3)); - */ - onPlayerDied(fn) { - if (typeof fn === 'function') _playerDiedHandlers.push(fn); - }, - /** Игрок прыгнул. */ - onPlayerJump(fn) { - if (typeof fn === 'function') _playerJumpHandlers.push(fn); - }, - /** Игрок приземлился (коснулся земли после полёта/прыжка). */ - onPlayerLand(fn) { - if (typeof fn === 'function') _playerLandHandlers.push(fn); - }, - /** - * UI / HUD — текст и счётчики поверх viewport в Play. - * game.ui.showText('Привет', 2) — флешит текст в центре - * game.ui.score = 100 — счётчик в углу - * game.ui.timer = 60 — таймер - * game.ui.set('hp', 'HP: 100', {color}) — произвольная именованная метка - * game.ui.remove('hp') - * game.ui.clear() — убрать всё - */ - ui: (() => { - const _state = { score: null, timer: null }; - return { - get score() { return _state.score; }, - set score(v) { _state.score = v; _send('ui.set', { id: '__score', text: v == null ? null : 'Очки: ' + v }); }, - get timer() { return _state.timer; }, - set timer(v) { - _state.timer = v; - if (v == null) { _send('ui.set', { id: '__timer', text: null }); return; } - const n = Number(v); - if (!Number.isFinite(n)) return; - const mm = Math.floor(Math.max(0, n) / 60); - const ss = Math.floor(Math.max(0, n) % 60); - const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; - _send('ui.set', { id: '__timer', text: txt }); - }, - /** Кратковременный текст по центру экрана. seconds=2 по умолчанию. */ - showText(text, seconds) { - _send('ui.flash', { - text: String(text == null ? '' : text), - seconds: Number.isFinite(Number(seconds)) ? Number(seconds) : 2, - }); - }, - /** - * Установить произвольную метку. - * id — уникальное имя для последующих обновлений и remove. - * opts: { x, y } — позиция в процентах (0..100), { color, size } — стилизация. - */ - set(id, text, opts) { - if (typeof id !== 'string' || !id) return; - _send('ui.set', { - id, - text: text == null ? null : String(text), - opts: opts || null, - }); - }, - /** Убрать метку по id. */ - remove(id) { - if (typeof id !== 'string' || !id) return; - _send('ui.set', { id, text: null }); - }, - /** Убрать весь HUD. */ - clear() { - _state.score = null; - _state.timer = null; - _send('ui.clear', null); - }, - }; - })(), - /** API сцены: spawn/delete/find/all. */ - scene: { - /** - * Создать объект на сцене. - * type: 'block:' / 'primitive:' / 'model:' / 'light:point'. - * opts: { x, y, z, sx, sy, sz, color, material, rotationY, name, lifetime, - * brightness, range }. - * lifetime — если задан (секунды), объект сам удалится через это время. - * brightness/range — только для 'light:point' (яркость и радиус лампы). - * Возвращает строку-ref (можно использовать в delete/getPosition). - */ - spawn(type, opts) { - if (typeof type !== 'string') return null; - opts = opts || {}; - // Алиас: 'light:point' — это примитив-лампа. - if (type === 'light:point' || type === 'light') type = 'primitive:light'; - const x = Number(opts.x) || 0; - const y = Number(opts.y) || 0; - const z = Number(opts.z) || 0; - const colon = type.indexOf(':'); - if (colon < 0) return null; - const kind = type.slice(0, colon); - const subType = type.slice(colon + 1); - if (kind === 'block') { - const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); - const ref = 'block:' + ix + ',' + iy + ',' + iz; - // color — для окрашиваемых блоков (studs-block, задача 09). - _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - if (kind === 'primitive' || kind === 'model') { - _localRefSeq++; - const ref = kind + ':_local_' + _localRefSeq; - _send('scene.spawn', { - kind, subType, x, y, z, - sx: opts.sx, sy: opts.sy, sz: opts.sz, - color: opts.color, material: opts.material, - rotationY: opts.rotationY, - name: opts.name, - brightness: opts.brightness, range: opts.range, - effect: opts.effect, - // anchored:false → объект падает (физика). По умолчанию - // примитив заякорен (anchored:true) и висит на месте. - anchored: opts.anchored, - // canCollide — можно сделать объект проходимым (зона). - canCollide: opts.canCollide, - // visible:false → объект скрыт (показать через setVisible). - visible: opts.visible, - // textureAsset — id картинки из ассетов проекта на грани. - textureAsset: opts.textureAsset, - ref, - }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - // Пользовательская модель из воксельного редактора моделей. - // type = 'user:', где — числовой id модели в проекте. - // ref пользовательских инстансов в сцене — 'usermodel:'. - if (kind === 'user') { - _localRefSeq++; - const ref = 'usermodel:_local_' + _localRefSeq; - _send('scene.spawn', { - kind: 'userModel', - // subType — это полная строка 'user:' (как принимает - // UserModelManager.addInstance). Восстанавливаем её. - subType: 'user:' + subType, - x, y, z, - rotationY: opts.rotationY, - name: opts.name, - ref, - }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - return null; - }, - /** Удалить объект по ref. */ - delete(ref) { - if (typeof ref !== 'string' || !ref) return; - _send('scene.delete', { ref }); - }, - /** - * Удалить объект через seconds секунд (авто-удаление). - * const p = game.scene.spawn('primitive:cube', { x, y, z }); - * game.scene.deleteAfter(p, 5); // исчезнет через 5 секунд - */ - deleteAfter(ref, seconds) { - _scheduleDelete(ref, seconds); - }, - /** - * Переместить объект (для моделей/примитивов) — без target-скрипта. - * ref — то что вернул spawn() или scene.find(). - */ - move(ref, x, y, z) { - if (typeof ref !== 'string') return; - const nx = Number(x), ny = Number(y), nz = Number(z); - if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) return; - // Парсим ref: 'primitive:_local_3' или 'primitive:realId' или 'model:id' - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive' && kind !== 'model') return; - _send('self.move', { - target: { kind, id, ref: id }, - x: nx, y: ny, z: nz, - }); - }, - /** - * Повернуть объект вокруг Y (в радианах). Только для примитивов. - */ - rotate(ref, ry) { - if (typeof ref !== 'string') return; - const r = Number(ry); - if (!Number.isFinite(r)) return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.rotate', { kind, id, rotationY: r }); - }, - /** - * Установить полный поворот (rx, ry, rz) в радианах. Для примитивов. - * Нужно для Кубикон Dash: куб крутится вокруг Z в воздухе. - */ - setRotation(ref, rx, ry, rz) { - if (typeof ref !== 'string') return; - const x = Number(rx), y = Number(ry), z = Number(rz); - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setRotation', { id, rx: x, ry: y, rz: z }); - }, - /** - * Изменить collision примитива (true = твёрдый, false = проваливается). - */ - setCollide(ref, can) { - if (typeof ref !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setCollide', { id, canCollide: !!can }); - }, - /** - * Изменить видимость примитива/модели (true = видно, false = скрыт). - */ - setVisible(ref, vis) { - if (typeof ref !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive' && kind !== 'model') return; - _send('scene.setVisible', { kind, id, visible: !!vis }); - }, - /** - * Изменить цвет примитива (hex-строка типа '#ff0000'). - */ - setColor(ref, color) { - if (typeof ref !== 'string' || typeof color !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setColor', { id, color }); - }, - /** - * Повесить текст-метку НАД объектом (имя/HP над персонажем, врагом). - * Метка всегда повёрнута к камере и видна поверх геометрии. - * game.scene.setLabel(enemy, 'Босс HP: 100', { color: '#ff4444' }); - * opts: { color: '#fff', height: 2.5 (м над объектом), size: 1 }. - * Работает для примитивов и моделей. - */ - setLabel(ref, text, opts) { - if (typeof ref !== 'string') return; - _send('scene.setLabel', { - ref, - text: String(text == null ? '' : text), - opts: opts || {}, - }); - }, - /** Убрать метку с объекта. */ - clearLabel(ref) { - if (typeof ref !== 'string') return; - _send('scene.clearLabel', { ref }); - }, - /** - * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. - * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). - */ - setOpacity(ref, value) { - if (typeof ref !== 'string') return; - const v = Number(value); - if (!Number.isFinite(v)) return; - if (ref.indexOf('primitive:') !== 0) return; - // шлём ПОЛНЫЙ ref — GameRuntime._resolvePrimitiveId резолвит - // локальный ref ('primitive:_local_N') через _localToReal. - _send('scene.setOpacity', { ref, opacity: Math.max(0, Math.min(1, v)) }); - }, - /** - * Масштаб примитива по осям. 1 = обычный размер, 2 = вдвое больше. - * Можно передать одно число (одинаково по всем осям) или три. - * game.scene.setScale(box, 2); // куб ×2 - * game.scene.setScale(box, 1, 3, 1); // вытянуть по Y - */ - setScale(ref, sx, sy, sz) { - if (typeof ref !== 'string') return; - let nx = Number(sx); - if (!Number.isFinite(nx) || nx <= 0) return; - let ny = Number(sy), nz = Number(sz); - // один аргумент → одинаково по всем осям - if (!Number.isFinite(ny)) ny = nx; - if (!Number.isFinite(nz)) nz = nx; - if (ref.indexOf('primitive:') !== 0) return; - _send('scene.setScale', { ref, sx: nx, sy: ny, sz: nz }); - }, - /** - * Материал примитива: 'default' | 'metal' | 'glass' | 'neon'. - * game.scene.setMaterial(box, 'neon'); // куб светится - */ - setMaterial(ref, name) { - if (typeof ref !== 'string' || typeof name !== 'string') return; - if (ref.indexOf('primitive:') !== 0) return; - _send('scene.setMaterial', { ref, material: name }); - }, - /** - * Создать копию примитива со смещением. Возвращает ref новой копии. - * const copy = game.scene.clone(box, { dx: 3 }); // копия на 3 правее - * offset: { dx, dy, dz } — смещение относительно оригинала. - */ - clone(ref, offset) { - if (typeof ref !== 'string') return null; - if (ref.indexOf('primitive:') !== 0) return null; - offset = offset || {}; - _localRefSeq++; - const newRef = 'primitive:_local_' + _localRefSeq; - _send('scene.clone', { - ref, - newRef, - dx: Number(offset.dx) || 0, - dy: Number(offset.dy) || 0, - dz: Number(offset.dz) || 0, - }); - return newRef; - }, - /** - * Установить динамическую текстуру примитива из dataURL. - * dataUrl — base64 PNG (например, из canvas.toDataURL()). - * Используется для GD-скинов: canvas-фабрика рисует лицо куба → шлёт сюда. - */ - setTexture(ref, dataUrl) { - if (typeof ref !== 'string' || typeof dataUrl !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setTexture', { id, dataUrl }); - }, - /** - * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ). - * Все примитивы внутри папки повернутся как единое целое. - * game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 }); - */ - setFolderYaw(folderName, angle, pivot) { - if (typeof folderName !== 'string') return; - const a = Number(angle); - if (!Number.isFinite(a)) return; - if (!pivot || !Number.isFinite(Number(pivot.x)) - || !Number.isFinite(Number(pivot.z))) return; - _send('scene.setFolderYaw', { - folderName, - angle: a, - pivot: { x: Number(pivot.x), z: Number(pivot.z) }, - }); - }, - /** - * Найти объекты по name. Возвращает массив Instance-прокси (паритет - * со студией). Instance coerces в строку-ref, поэтому код, принимавший - * строковый ref, продолжает работать. - */ - find(name) { - const out = []; - const n = String(name || '').toLowerCase(); - for (const m of _sceneIndex.models) { - if (m.name && String(m.name).toLowerCase() === n) { - out.push(_getOrCreateInstance(m.ref, 'model')); - } - } - for (const p of _sceneIndex.primitives) { - if (p.name && String(p.name).toLowerCase() === n) { - out.push(_getOrCreateInstance(p.ref, 'primitive')); - } - } - return out; - }, - /** Первый объект с таким name или null. */ - findOne(name) { - const arr = this.find(name); - return arr.length > 0 ? arr[0] : null; - }, - /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ - all(kind) { - if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); - if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); - if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); - return []; - }, - /** - * Сохранить произвольное значение НА объекте (атрибут). - * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает. - * game.scene.setData(door, 'locked', true); - * game.scene.setData(chest, 'gold', 100); - */ - setData(ref, key, value) { - if (typeof ref !== 'string' || typeof key !== 'string') return; - // оптимистично обновляем локальное зеркало (до прихода снапшота) - if (!_dataIndex[ref]) _dataIndex[ref] = {}; - _dataIndex[ref][key] = value; - _send('scene.setData', { ref, key, value }); - }, - /** - * Прочитать атрибут объекта. Возвращает значение или undefined. - * if (game.scene.getData(door, 'locked')) { ... } - */ - getData(ref, key) { - if (typeof ref !== 'string' || typeof key !== 'string') return undefined; - const bag = _dataIndex[ref]; - return bag ? bag[key] : undefined; - }, - /** - * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. - * Помечаешь объекты тегом, потом находишь все объекты с тегом. - * game.scene.tag(enemy, 'враг'); - * for (const e of game.scene.getTagged('враг')) { ... } - */ - tag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - // Оптимистично обновляем локальное зеркало (до прихода снапшота). - if (!_dataIndex[ref]) _dataIndex[ref] = {}; - const cur = Array.isArray(_dataIndex[ref].__tags) ? _dataIndex[ref].__tags : []; - if (!cur.includes(tag)) _dataIndex[ref].__tags = [...cur, tag]; - _send('scene.tag', { ref, tag }); - }, - /** Снять тег с объекта. */ - untag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - if (_dataIndex[ref] && Array.isArray(_dataIndex[ref].__tags)) { - _dataIndex[ref].__tags = _dataIndex[ref].__tags.filter(t => t !== tag); - } - _send('scene.untag', { ref, tag }); - }, - /** True если у объекта есть такой тег. */ - hasTag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string') return false; - const bag = _dataIndex[ref]; - return !!(bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)); - }, - /** Список ref всех объектов с заданным тегом. */ - getTagged(tag) { - if (typeof tag !== 'string' || !tag) return []; - const out = []; - for (const ref of Object.keys(_dataIndex)) { - const bag = _dataIndex[ref]; - if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)) { - out.push(ref); - } - } - return out; - }, - /** Позиция объекта по ref или null. Работает и с локальным ref - * от scene.spawn (резолвит в реальный через _spawnLocalToReal). */ - getPosition(ref) { - if (typeof ref !== 'string') return null; - // Локальный ref scene.spawn → реальный. - const r = _spawnLocalToReal[ref] || ref; - for (const b of _sceneIndex.blocks) if (b.ref === r) return { x: b.x, y: b.y, z: b.z }; - for (const m of _sceneIndex.models) if (m.ref === r) return { x: m.x, y: m.y, z: m.z }; - for (const p of _sceneIndex.primitives) if (p.ref === r) return { x: p.x, y: p.y, z: p.z }; - return null; - }, - /** - * Создать NPC — управляемого скриптом персонажа (Фаза 4.1). - * modelType — id модели (как в game.scene.spawn('model:...')). - * opts: { x, y, z, rotationY, hp, name, speed }. - * Возвращает объект-NPC с методами: - * npc.moveTo(x, z) — идти в точку - * npc.follow(ref) — следовать за объектом ('player' или ref) - * npc.stop() — остановиться - * npc.say(text, sec) — реплика над головой - * npc.damage(amount) — нанести урон - * npc.remove() — убрать со сцены - * npc.onDeath(fn) — колбэк при гибели NPC - * npc.position — {x,y,z} (актуальная позиция) - * npc.hp / npc.name — текущие значения - * npc.ref — строковый ref NPC - * - * const trader = game.scene.spawnNpc('robot', { x: 5, z: 0, name: 'Боб' }); - * trader.say('Привет!'); - * trader.follow('player'); - */ - spawnNpc(modelType, opts) { - if (typeof modelType !== 'string') return null; - opts = opts || {}; - _npcRefSeq++; - const ref = 'npc:_local_' + _npcRefSeq; - _send('npc.spawn', { - modelType, ref, - x: Number(opts.x) || 0, - y: Number(opts.y) || 0, - z: Number(opts.z) || 0, - rotationY: Number(opts.rotationY) || 0, - hp: Number.isFinite(Number(opts.hp)) ? Number(opts.hp) : undefined, - name: typeof opts.name === 'string' ? opts.name : undefined, - speed: Number.isFinite(Number(opts.speed)) ? Number(opts.speed) : undefined, - }); - return _makeNpcProxy(ref); - }, - /** Список всех NPC на сцене — массив объектов {id, name, x,y,z, hp, ...}. */ - npcs() { - return _npcs.map(n => ({ ...n })); - }, - /** - * Эффект частиц в точке. Авто-удаляется через duration секунд. - * type: 'fire' | 'smoke' | 'sparks' | 'magic' | 'explosion' | 'confetti' - * position: {x,y,z} - * options: { duration: 2 (сек), count: 50 (множитель), color: '#ffaa00' } - */ - spawnParticles(type, position, options) { - if (typeof type !== 'string' || !position) return; - _send('scene.particles', { - type, - position: { - x: Number(position.x) || 0, - y: Number(position.y) || 0, - z: Number(position.z) || 0, - }, - duration: options && Number.isFinite(Number(options.duration)) ? Number(options.duration) : 1.5, - count: options && Number.isFinite(Number(options.count)) ? Number(options.count) : 1, - color: options?.color || null, - }); - }, - /** - * Снимок всех живых мобов (зомби и т.д.). Обновляется каждый tick. - * Возвращает массив { id, mobType, x, y, z, hp }. - * Опциональный фильтр: { mobType?: 'zombie', within?: { x, y?, z, radius } } - */ - mobs(filter) { - const arr = _mobs.slice(); - if (!filter) return arr; - const wantType = typeof filter.mobType === 'string' ? filter.mobType : null; - const within = filter.within; - if (wantType == null && !within) return arr; - const out = []; - const wx = within ? Number(within.x) || 0 : 0; - const wz = within ? Number(within.z) || 0 : 0; - const wr2 = within ? (Number(within.radius) || 0) ** 2 : 0; - for (const m of arr) { - if (wantType && m.mobType !== wantType) continue; - if (within) { - const dx = m.x - wx, dz = m.z - wz; - if (dx*dx + dz*dz > wr2) continue; - } - out.push(m); - } - return out; - }, - /** - * Убить моба (или массив мобов). Принимает объект из mobs() или его id. - * Запускает обычную смерть (с эффектами + onMobKilled). - */ - killMob(target) { - if (target == null) return; - const items = Array.isArray(target) ? target : [target]; - for (const it of items) { - let id = null; - if (typeof it === 'number') id = it; - else if (it && typeof it === 'object' && 'id' in it) id = Number(it.id); - if (Number.isFinite(id)) _send('mob.kill', { id }); - } - }, - /** - * Высота поверхности гладкого ландшафта в точке (x, z). - * Билинейная интерполяция по карте высот (raycast по реальному - * мешу, снятой при старте). Нужно чтобы скрипты ставили объекты - * (животных и т.п.) ТОЧНО на землю, а не парили/тонули. - * - * Возвращает Y поверхности, или null если карта высот не пришла - * (нет гладкого ландшафта в проекте). - * - * const y = game.scene.surfaceY(p.x, p.z); - * if (y !== null) game.self.move(nx, y, nz); - */ - surfaceY(x, z) { - const hm = _terrainHM; - if (!hm || !hm.heights) return null; - const nx = Number(x), nz = Number(z); - if (!Number.isFinite(nx) || !Number.isFinite(nz)) return null; - const fx = (nx - hm.origin.x) / hm.step; - const fz = (nz - hm.origin.z) / hm.step; - let c0 = Math.floor(fx), r0 = Math.floor(fz); - // clamp в пределы карты - if (c0 < 0) c0 = 0; if (c0 > hm.cols - 2) c0 = hm.cols - 2; - if (r0 < 0) r0 = 0; if (r0 > hm.rows - 2) r0 = hm.rows - 2; - const tx = Math.max(0, Math.min(1, fx - c0)); - const tz = Math.max(0, Math.min(1, fz - r0)); - const H = hm.heights; - const W = hm.cols; - const h00 = H[r0 * W + c0]; - const h10 = H[r0 * W + c0 + 1]; - const h01 = H[(r0 + 1) * W + c0]; - const h11 = H[(r0 + 1) * W + c0 + 1]; - // null-ячейки заменяем на среднее валидных - const vals = []; - if (h00 != null) vals.push(h00); - if (h10 != null) vals.push(h10); - if (h01 != null) vals.push(h01); - if (h11 != null) vals.push(h11); - if (vals.length === 0) return null; - const avg = vals.reduce((a, b) => a + b, 0) / vals.length; - const v00 = h00 != null ? h00 : avg; - const v10 = h10 != null ? h10 : avg; - const v01 = h01 != null ? h01 : avg; - const v11 = h11 != null ? h11 : avg; - const a = v00 * (1 - tx) + v10 * tx; - const b = v01 * (1 - tx) + v11 * tx; - return a * (1 - tz) + b * tz; - }, - }, - - /** - * Физика — луч (raycast), импульсы, взрывы. - */ - physics: { - /** - * Пустить луч из точки origin в направлении dir. - * Возвращает { hit, ref, point, distance } — hit=true если во что-то - * попали. ref — объект (primitive) в который попали. - * Синхронный — можно звать прямо в onClick для стрельбы. - * const r = game.physics.raycast(game.player.position, game.player.forward); - * if (r.hit) game.scene.delete(r.ref); - * opts: { maxDistance: 100, ignore: [ref, ...] } - */ - raycast(origin, dir, opts) { - opts = opts || {}; - const ox = Number(origin?.x), oy = Number(origin?.y), oz = Number(origin?.z); - let dx = Number(dir?.x), dy = Number(dir?.y), dz = Number(dir?.z); - if (![ox, oy, oz, dx, dy, dz].every(Number.isFinite)) { - return { hit: false, ref: null, point: null, distance: Infinity }; - } - // нормализуем направление - const dlen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; - dx /= dlen; dy /= dlen; dz /= dlen; - const maxDist = Number.isFinite(Number(opts.maxDistance)) ? Number(opts.maxDistance) : 100; - const ignore = Array.isArray(opts.ignore) ? opts.ignore : []; - let best = { hit: false, ref: null, point: null, distance: Infinity }; - // перебираем примитивы — ray vs AABB (с учётом поворота вокруг Y) - for (const p of _sceneIndex.primitives) { - if (p.visible === false) continue; - if (ignore.includes(p.ref)) continue; - const hw = (p.sx || 1) / 2, hh = (p.sy || 1) / 2, hd = (p.sz || 1) / 2; - // переводим луч в локальные координаты примитива (обратный поворот по Y) - const ang = -(p.rotationY || 0); - const cos = Math.cos(ang), sin = Math.sin(ang); - const rx = ox - p.x, rz = oz - p.z; - const lox = rx * cos - rz * sin; - const loz = rx * sin + rz * cos; - const loy = oy - p.y; - const ldx = dx * cos - dz * sin; - const ldz = dx * sin + dz * cos; - const ldy = dy; - // slab-тест ray vs AABB - const t = _rayAabb(lox, loy, loz, ldx, ldy, ldz, hw, hh, hd); - if (t != null && t >= 0 && t <= maxDist && t < best.distance) { - best = { - hit: true, ref: p.ref, distance: t, - point: { x: ox + dx*t, y: oy + dy*t, z: oz + dz*t }, - }; - } - } - return best; - }, - /** - * Задать скорость объекту (м/с). Объект полетит с этой скоростью. - * game.physics.setVelocity(ball, { x: 0, y: 10, z: 5 }); - */ - setVelocity(ref, vel) { - if (typeof ref !== 'string' || !vel) return; - _send('physics.setVelocity', { - ref, - vx: Number(vel.x) || 0, vy: Number(vel.y) || 0, vz: Number(vel.z) || 0, - }); - }, - /** - * Толкнуть объект импульсом (резкий толчок). - * game.physics.applyImpulse(box, { x: 15, y: 5, z: 0 }); - */ - applyImpulse(ref, impulse) { - if (typeof ref !== 'string' || !impulse) return; - _send('physics.applyImpulse', { - ref, - ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0, - }); - }, - /** - * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе. - * game.physics.explode({ x, y, z }, 5, { damage: 40 }); - * opts: { damage: 30, force: 0 } — урон и сила отброса. - */ - explode(pos, radius, opts) { - if (!pos) return; - opts = opts || {}; - _send('physics.explode', { - x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, - radius: Number(radius) || 3, - damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30, - force: Number(opts.force) || 0, - }); - }, - /** - * Проходимость объекта или группы (Фаза 5.9, collision groups). - * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам - * с этим тегом — теги работают как collision groups). - * on=true — игрок проходит сквозь (объект остаётся видимым), - * on=false — снова твёрдый. - * game.physics.passThrough(wall, true); // одна стена - * game.physics.passThrough('призраки', true); // вся группа по тегу - */ - passThrough(target, on) { - if (typeof target !== 'string' || !target) return; - _send('physics.passThrough', { target, on: !!on }); - }, - }, - - /** - * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов. - */ - gui: { - /** Найти ID элемента по имени. Возвращает строку или null. */ - find(name) { - if (typeof name !== 'string') return null; - const n = name.toLowerCase(); - for (const g of _guiIndex) { - if (g.name && String(g.name).toLowerCase() === n) return g.id; - } - return null; - }, - /** Список ID всех элементов. */ - all() { - return _guiIndex.map(g => g.id); - }, - /** Получить копию свойств элемента. */ - get(id) { - if (typeof id !== 'string') return null; - const found = _guiIndex.find(g => g.id === id); - return found ? { ...found } : null; - }, - /** - * Изменить свойства элемента. - * game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' }); - */ - update(id, patch) { - if (typeof id !== 'string' || !patch || typeof patch !== 'object') return; - _send('gui.update', { id, patch }); - }, - /** Сделать элемент видимым. */ - show(id) { - if (typeof id !== 'string') return; - _send('gui.update', { id, patch: { visible: true } }); - }, - /** Скрыть элемент (но не удалять). */ - hide(id) { - if (typeof id !== 'string') return; - _send('gui.update', { id, patch: { visible: false } }); - }, - /** - * Создать новый элемент. Возвращает локальный ref-id (строку). - * const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' }); - */ - create(type, opts) { - if (typeof type !== 'string') return null; - _localRefSeq++; - const localRef = '_gui_local_' + _localRefSeq; - _send('gui.create', { type, opts: opts || {}, localRef }); - return localRef; - }, - /** Удалить элемент по id. */ - remove(id) { - if (typeof id !== 'string') return; - _send('gui.remove', { id }); - }, - /** - * Подписаться на клик по кнопке (по id). - * game.gui.onClick('gui_xxx', () => { game.log('clicked!'); }); - */ - onClick(id, fn) { - if (typeof id !== 'string' || typeof fn !== 'function') return; - (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn); - }, - /** - * Подписаться на ввод в поле TextBox — срабатывает когда игрок - * нажал Enter. fn получает введённый текст. - * game.gui.onSubmit('gui_name', (text) => { - * game.ui.showText('Привет, ' + text); - * }); - */ - onSubmit(id, fn) { - if (typeof id !== 'string' || typeof fn !== 'function') return; - (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); - }, - /** Задача 03: tween свойства GUI-элемента. - * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, - * bgColor, textColor, borderColor } (любое числовое или hex-цвет). - * opts: { duration, easing, delay, repeat, reverses, onDone } */ - tween(id, props, opts) { - if (typeof id !== 'string' || !id) return null; - if (!props || typeof props !== 'object') return null; - opts = opts || {}; - const tid = ++_tweenSeq; - if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; - _send('gui.tween', { - tweenId: tid, id, props, - duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, - easing: typeof opts.easing === 'string' ? opts.easing : 'ease', - delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, - repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, - reverses: !!opts.reverses, - }); - return tid; - }, - /** Отменить tween по id (возвращённому из game.gui.tween). */ - cancelTween(tweenId) { - if (!Number.isFinite(tweenId)) return; - _send('gui.cancelTween', { tweenId }); - delete _tweenCallbacks[tweenId]; - }, - }, - /** - * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). - */ - camera: { - /** - * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно), - * dur в секундах. Затухает к 0. - */ - shake(amp, dur) { - const a = Number(amp), d = Number(dur); - if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return; - _send('camera.shake', { amp: a, dur: d }); - }, - /** - * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий, - * 40 — «зум». Диапазон 10..130. - * game.camera.setFov(90); - */ - setFov(degrees) { - const d = Number(degrees); - if (Number.isFinite(d)) _send('camera.fov', { degrees: d }); - }, - /** - * Привязать камеру к объекту — она следит за ним. - * ref — объект сцены. opts: { distance, height } — отступ камеры. - * game.camera.focusOn(bossRef, { distance: 12, height: 6 }); - */ - focusOn(ref, opts) { - if (typeof ref !== 'string') return; - opts = opts || {}; - _send('camera.focus', { - ref, - distance: Number.isFinite(Number(opts.distance)) ? Number(opts.distance) : undefined, - height: Number.isFinite(Number(opts.height)) ? Number(opts.height) : undefined, - }); - }, - /** - * Катсцена — плавный пролёт камеры по точкам. - * points — массив позиций камеры [{x,y,z}, ...]. - * opts: { lookAt: [{x,y,z}, ...] — точки взгляда (по одной на - * позицию), segDuration: секунд на отрезок }. - * game.camera.cutscene( - * [{x:0,y:10,z:-20}, {x:0,y:5,z:0}], - * { lookAt: [{x:0,y:0,z:0}, {x:0,y:0,z:0}], segDuration: 3 } - * ); - */ - cutscene(points, opts) { - if (!Array.isArray(points) || points.length < 2) return; - opts = opts || {}; - _send('camera.cutscene', { - points, - lookAt: Array.isArray(opts.lookAt) ? opts.lookAt : [], - segDuration: Number.isFinite(Number(opts.segDuration)) - ? Number(opts.segDuration) : 2, - }); - }, - /** Вернуть камеру под управление игрока. */ - reset() { - _send('camera.reset', {}); - }, - }, - /** - * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат). - * Нужно для игр которые делают свой UI через game.gui.* и не хотят - * чтобы стандартные элементы мешали. - */ - hud: { - /** Скрыть/показать ВСЕ стандартные HUD-элементы. */ - setVisible(visible) { - _send('hud.setVisible', { visible: !!visible }); - }, - /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). - * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ - setHotbarVisible(visible) { - _send('hud.setHotbarVisible', { visible: !!visible }); - }, - /** Скрыть/показать только HP-индикатор (полоска жизней). */ - setHpVisible(visible) { - _send('hud.setHpVisible', { visible: !!visible }); - }, - }, - /** - * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). - * - * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. - * - * const m = game.modal.open({ - * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) - * darkenColor: '#000', // цвет затемнения - * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) - * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) - * freezeCamera: true, // камера замирает - * fadeIn: 0.4, // секунды до полного затемнения - * fadeOut: 0.3, - * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) - * spotlightRadius: 120, // пиксели — радиус «прожектора» - * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) - * muteWorld: false, // приглушает ambient/sfx - * cameraOverride: { // фокус камеры на цель - * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, - * }, - * content: { elements: [ // временные GUI поверх модала, удалятся при close - * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, - * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, - * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, - * ]}, - * }); - * game.gui.onClick('fight', () => game.modal.close(m)); - * - * Готовые пресеты: - * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром - * game.modal.lootbox(items, onPick) — открытие лутбокса - * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно - * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет - * - * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. - */ - modal: { - _localSeq: 0, - _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) - _onCloseFns: [], - open(opts) { - opts = opts || {}; - const localId = ++this._localSeq; - const replyId = '_mopen_' + localId; - _send('modal.open', { opts, replyId }); - // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event - return localId; - }, - close(modalId) { - // Резолвим локальный id → реальный. Если modalId — локальное число, но - // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал - // одиночный, null закрывает активный. Передавать локальный id нельзя — - // ModalManager.close сверяет его со своим _state.id и молча игнорит. - let real = null; - if (typeof modalId === 'number') { - real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; - } else if (modalId != null) { - real = modalId; // уже реальный id (строка/число от runtime) - } - _send('modal.close', { modalId: real }); - }, - update(modalId, patch) { - let real = null; - if (typeof modalId === 'number') { - real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; - } else if (modalId != null) { - real = modalId; - } - _send('modal.update', { modalId: real, patch: patch || {} }); - }, - isOpen() { return !!this._isOpenLocal; }, - onClose(fn) { - if (typeof fn === 'function') this._onCloseFns.push(fn); - }, - - // === Пресеты === - /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ - bossIntro(name, hp, refs, opts) { - opts = opts || {}; - const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; - const buttonText = opts.buttonText || 'В бой!'; - const onStart = opts.onStart; - const elements = [ - { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', - text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', - textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, - animationPreset: 'glow' }, - { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', - text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', - textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, - ]; - const m = this.open({ - darken: 0.7, target: 'scene', - blockInput: true, freezeCamera: true, - spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), - cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, - distance: 8, height: 3, fov: 60, duration: 0.5 } : null, - content: { elements }, - }); - const _modal = this; - const _afterTid = ++_timerSeq; - _timers.push({ id: _afterTid, fn: () => { - _send('gui.create', { type: 'button', opts: { - id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', - text: buttonText, - bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, - borderColor: '#000', borderWidth: 3, borderRadius: 14, - textColor: '#fff', textSize: 22, fontWeight: 900, - textStroke: { color: '#000', width: 2 }, - hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, - active: { scale: 0.94, duration: 0.08 }, - animationPreset: 'pulse', - }, localRef: '_boss_start' }); - let _started = false; - _guiClickHandlers['_boss_start'] = [() => { - if (_started) return; - _started = true; - delete _guiClickHandlers['_boss_start']; - _modal.close(m); - if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } - }]; - }, delay: startBtnDelay, elapsed: 0, repeat: false }); - return m; - }, - /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ - lootbox(items, onPick) { - items = Array.isArray(items) ? items.slice(0, 5) : []; - const elements = [ - { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', - bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, - borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, - { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', - text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', - textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, - animationPreset: 'glow' }, - ]; - for (let i = 0; i < items.length; i++) { - const it = items[i]; - const x = 50 + (i - (items.length - 1) / 2) * 13; - elements.push({ - kind: 'button', id: '_lb_item_' + i, - x: x, y: 50, w: 11, h: 16, anchor: 'center', - text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), - bgColor: it.color || '#3a3a5a', borderRadius: 12, - borderColor: '#ffd700', borderWidth: 2, - textColor: '#fff', textSize: 14, fontWeight: 700, - hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, - active: { scale: 0.94, duration: 0.08 }, - animationPreset: 'pulse', - }); - } - const m = this.open({ - darken: 0.6, target: 'screen', blockInput: true, - content: { elements }, - }); - const _modal = this; - // _picked: после первого выбора остальные карточки не должны срабатывать, - // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). - let _picked = false; - for (let i = 0; i < items.length; i++) { - const id = '_lb_item_' + i; - const it = items[i]; - _guiClickHandlers[id] = [() => { - if (_picked) return; - _picked = true; - for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; - _modal.close(m); - if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } - }]; - } - return m; - }, - /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ - dialog(npcName, lines, onDone) { - lines = Array.isArray(lines) ? lines : [String(lines || '')]; - let idx = 0; - const elements = [ - { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', - bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, - borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, - { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', - text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, - textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, - bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', - text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', - textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', - // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», - // на остальных — стрелку «дальше». - text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, - bgColor: '#ffd700', textColor: '#000', borderRadius: 8, - borderColor: '#000', borderWidth: 2, - hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, - animationPreset: 'pulse' }, - ]; - const m = this.open({ - darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, - content: { elements }, - }); - const _modal = this; - // _done защищает от повторного срабатывания: game.modal.close() доигрывает - // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый - // лишний клик снова звал onDone (баг «Диалог завершён ×7»). - let _done = false; - _guiClickHandlers['_dlg_next'] = [() => { - if (_done) return; - idx++; - if (idx < lines.length) { - _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); - // Последняя строка достигнута — превращаем «дальше» в «завершить». - if (idx === lines.length - 1) { - _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); - } - } else { - _done = true; - delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу - _modal.close(m); - if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } - } - }]; - return m; - }, - /** Подтверждение Да/Нет. */ - confirmation(title, body, onYes, onNo) { - const elements = [ - { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', - bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, - borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, - { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', - text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, - textColor: '#fff', textStroke: { color: '#000', width: 2 }, - bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', - text: String(body || ''), textSize: 16, fontWeight: 500, - textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', - text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, - borderColor: '#000', borderWidth: 2, borderRadius: 10, - textColor: '#fff', textSize: 18, fontWeight: 900, - hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, - { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', - text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, - borderColor: '#000', borderWidth: 2, borderRadius: 10, - textColor: '#fff', textSize: 18, fontWeight: 900, - hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, - ]; - const m = this.open({ - darken: 0.6, target: 'screen', blockInput: true, - content: { elements }, - }); - const _modal = this; - // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал - // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. - let _answered = false; - const _finish = (cb) => { - if (_answered) return; - _answered = true; - delete _guiClickHandlers['_cf_yes']; - delete _guiClickHandlers['_cf_no']; - _modal.close(m); - if (typeof cb === 'function') { try { cb(); } catch (e) {} } - }; - _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; - _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; - return m; - }, - }, - /** - * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. - * game.inventory.add({ name: 'Зелье', kind: 'item' }) - * game.inventory.has('Зелье') — по имени или modelTypeId - * game.inventory.remove('Зелье') - * game.inventory.list() — массив предметов - * game.inventory.clear() - */ - inventory: { - /** Добавить предмет. item: { name, kind?, modelTypeId?, params? }. */ - add(item) { - if (!item || typeof item !== 'object') return; - _send('inventory.give', { - kind: item.kind || 'item', - modelTypeId: item.modelTypeId || null, - name: item.name || 'Предмет', - params: item.params || {}, - }); - }, - /** Убрать первый предмет по имени или modelTypeId. */ - remove(nameOrModel) { - if (typeof nameOrModel !== 'string') return; - _send('inventory.remove', { name: nameOrModel, modelTypeId: nameOrModel }); - }, - /** True если предмет с таким именем/modelTypeId есть в инвентаре. */ - has(nameOrModel) { - if (typeof nameOrModel !== 'string') return false; - return (_inventory.slots || []).some(s => - s && (s.name === nameOrModel || s.modelTypeId === nameOrModel)); - }, - /** Массив всех предметов инвентаря (без пустых слотов). */ - list() { - return (_inventory.slots || []).filter(Boolean).map(s => ({ ...s })); - }, - /** Активный предмет (выбранный слот hot-bar) или null. */ - active() { - const s = (_inventory.slots || [])[_inventory.activeIndex]; - return s ? { ...s } : null; - }, - /** Очистить весь инвентарь. */ - clear() { - _send('inventory.clear', {}); - }, - }, - /** - * Игроки комнаты (Фаза 4.3 — мультиплеер). - * В одиночной игре (редактор) — только локальный игрок. - * game.players.me() — я { sessionId, name, position, hp, ... } - * game.players.all() — массив всех игроков (включая меня) - * game.players.count() — сколько игроков - */ - players: { - /** Локальный игрок. */ - me() { - return _players.me ? { ..._players.me } : null; - }, - /** Все игроки комнаты (включая меня). */ - all() { - return (_players.list || []).map(p => ({ ...p })); - }, - /** Сколько игроков в комнате. */ - count() { - return (_players.list || []).length; - }, - /** Найти игрока по sessionId или null. */ - get(sessionId) { - const p = (_players.list || []).find(x => x.sessionId === sessionId); - return p ? { ...p } : null; - }, - }, - /** - * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. - * В одиночной игре работает как локальное хранилище. - * game.room.set('счёт', 10) - * game.room.get('счёт') → 10 - * game.room.onChange('счёт', (v) => game.ui.showText('Счёт: ' + v)) - */ - room: { - /** Установить значение в общем состоянии комнаты. */ - set(key, value) { - if (typeof key !== 'string' || !key) return; - // Оптимистично обновляем локальное зеркало. - _roomState[key] = value; - _send('room.set', { key, value }); - }, - /** Прочитать значение из общего состояния комнаты. */ - get(key) { - if (typeof key !== 'string') return undefined; - return _roomState[key]; - }, - /** Подписаться на изменение ключа общего состояния. fn(value). */ - onChange(key, fn) { - if (typeof key !== 'string' || typeof fn !== 'function') return; - (_roomChangeHandlers[key] = _roomChangeHandlers[key] || []).push(fn); - }, - }, - /** - * Команды (Фаза 4.4) — для командных игр. - * game.teams.create('Красные', '#ff3333') - * game.teams.create('Синие', '#3366ff') - * game.player.setTeam('Красные') - * game.player.team → 'Красные' - */ - teams: { - /** Создать команду с именем и цветом (#hex). */ - create(name, color) { - if (typeof name !== 'string' || !name) return; - _send('teams.create', { - name, - color: typeof color === 'string' ? color : '#888888', - }); - }, - /** Удалить команду. */ - remove(name) { - if (typeof name !== 'string') return; - _send('teams.remove', { name }); - }, - /** Список всех команд — массив { name, color }. */ - all() { - return _teams.map(t => ({ ...t })); - }, - /** Найти команду по имени или null. */ - get(name) { - const t = _teams.find(x => x.name === name); - return t ? { ...t } : null; - }, - }, - /** - * Связи между объектами (Фаза 5, Constraints). - * weld — склейка: объект B движется вместе с A. - * hinge — петля: вращение вокруг оси (двери, рычаги). - * spring — пружина: упругое колебание (батуты). - * Каждый вызов возвращает объект-связь с методами управления. - */ - constraints: { - /** - * Жёстко склеить объект B с A — B следует за A. - * game.constraints.weld(platformRef, crateRef); - */ - weld(refA, refB) { - if (typeof refA !== 'string' || typeof refB !== 'string') return null; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { kind: 'weld', localRef, refA, refB }); - return { - get ref() { return localRef; }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - /** - * Петля: объект вращается вокруг вертикальной оси через pivot. - * opts: { pivotX, pivotZ — точка оси, angle — стартовый угол (°) }. - * Метод setAngle(°) поворачивает объект — для дверей/рычагов. - * const door = game.constraints.hinge(doorRef, { pivotX: 5, pivotZ: 0 }); - * door.setAngle(90); // открыть дверь - */ - hinge(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { - kind: 'hinge', localRef, ref, - pivotX: Number.isFinite(Number(opts.pivotX)) ? Number(opts.pivotX) : undefined, - pivotZ: Number.isFinite(Number(opts.pivotZ)) ? Number(opts.pivotZ) : undefined, - angle: Number(opts.angle) || 0, - }); - return { - get ref() { return localRef; }, - /** Повернуть к углу (градусы) — объект плавно довернётся. */ - setAngle(deg) { - _send('constraint.hingeAngle', { ref: localRef, deg: Number(deg) || 0 }); - }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - /** - * Пружина: объект упруго держится в точке покоя (текущая позиция). - * opts: { stiffness — жёсткость, damping — затухание }. - * Метод push(vx,vy,vz) толкает объект — запускает колебание. - * const trampoline = game.constraints.spring(padRef); - * trampoline.push(0, 12, 0); // подбросить вверх - */ - spring(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { - kind: 'spring', localRef, ref, - stiffness: Number.isFinite(Number(opts.stiffness)) ? Number(opts.stiffness) : undefined, - damping: Number.isFinite(Number(opts.damping)) ? Number(opts.damping) : undefined, - }); - return { - get ref() { return localRef; }, - /** Толкнуть объект (скорость по осям) — запускает колебание. */ - push(vx, vy, vz) { - _send('constraint.springPush', { - ref: localRef, - vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0, - }); - }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - }, - /** - * Эффекты-объекты сцены (Фаза 5.2): лучи и следы. - * beam — светящаяся линия между точками (лазеры, мосты, цепи). - * trail — шлейф за движущимся объектом. - */ - fx: { - /** - * Луч между двумя точками. opts: { from, to — {x,y,z} или ref - * объекта (тогда луч следит за ним); color: '#hex', width }. - * game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' }); - */ - beam(opts) { - opts = opts || {}; - _fxRefSeq++; - const localRef = 'fx:_local_' + _fxRefSeq; - // Задача 08: расширенные опции (текстура/curved/градиент/billboard). - _send('fx.create', { - kind: 'beam', localRef, - from: _normFxPoint(opts.from), to: _normFxPoint(opts.to), - color: typeof opts.color === 'string' ? opts.color : 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 { - get ref() { return localRef; }, - /** Сменить цвет луча. */ - setColor(color) { - _send('fx.beamColor', { ref: localRef, color }); - }, - /** Сменить концы луча ({x,y,z} или ref). */ - setEndpoints(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 }); }, - }; - }, - /** - * Шлейф за объектом. ref — ref-строка объекта. - * opts: { color: '#hex', width, lifetime (сек) }. - * game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 }); - */ - trail(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _fxRefSeq++; - const localRef = 'fx:_local_' + _fxRefSeq; - _send('fx.create', { - kind: 'trail', localRef, ref, - color: typeof opts.color === 'string' ? opts.color : undefined, - width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, - lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined, - }); - return { - get ref() { return localRef; }, - remove() { _send('fx.remove', { ref: localRef }); }, - }; - }, - }, - /** - * Звуки. Два вида: - * 1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click', - * 'hit', 'coin' — game.sound.play('jump'). - * 2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D: - * game.sound.play('sound_1', { at: {x,y,z} }) — 3D в точке - * game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта - * game.sound.play('sound_1', { loop: true }) — зациклить - */ - sound: { - /** - * Проиграть звук. id — пресет ('jump'...) или 'sound_N'. - * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }. - * Для пользовательского звука возвращает объект с методом stop(). - */ - play(id, opts) { - if (typeof id !== 'string' || !id) return null; - opts = opts || {}; - // Встроенный пресет — старый формат {name}. - if (id.indexOf('sound_') !== 0) { - _send('sound.play', { - name: id, - volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, - pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1, - }); - return null; - } - // Пользовательский звук из библиотеки проекта. - _soundRefSeq++; - const localRef = 'sound:_local_' + _soundRefSeq; - _send('sound.play', { - soundId: id, localRef, - volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, - loop: !!opts.loop, - at: (opts.at && Number.isFinite(Number(opts.at.x))) - ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 } - : undefined, - attachRef: typeof opts.attach === 'string' ? opts.attach : undefined, - }); - return { - get ref() { return localRef; }, - /** Остановить этот звук. */ - stop() { _send('sound.stop', { ref: localRef }); }, - }; - }, - }, - /** - * Аудио — GD-музыка и SFX. - * game.audio.playSfx('jump') — короткий звук (jump/death/orb_tap/...) - * game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная) - * game.audio.stopMusic() - * game.audio.setMuted(true) - */ - audio: { - playSfx(name) { - if (typeof name !== 'string') return; - _send('audio.playSfx', { name }); - }, - playMusic(trackId) { - if (typeof trackId !== 'string') return; - _send('audio.playMusic', { trackId }); - }, - stopMusic() { - _send('audio.stopMusic', {}); - }, - setMuted(muted) { - _send('audio.setMuted', { muted: !!muted }); - }, - }, - /** - * Экономика — алмазы и рейтинг через серверные API. - * Все вызовы асинхронные (с callback), потому что идут через HTTP. - * - * game.economy.reward('level_1_first_pass', function(res) { - * // res = { ok, already_awarded, diamonds, rating, ... } - * }); - * game.economy.dailyCheck(function(res) { ... }); - * game.economy.getBalance(function(res) { - * // res = { diamonds, rating } - * }); - */ - economy: { - reward(achievementId, fn) { - if (typeof achievementId !== 'string') return; - const reqId = 'eco_rwd_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.reward', { reqId, achievementId }); - }, - dailyCheck(fn) { - const reqId = 'eco_daily_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.dailyCheck', { reqId }); - }, - getBalance(fn) { - const reqId = 'eco_bal_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.getBalance', { reqId }); - }, - spend(amount, reason, fn) { - const reqId = 'eco_spend_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); - }, - }, - /** - * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox). - * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}), - * затем настраиваются через game.billboard.set/update. - * - * Пресеты (template): - * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены - * - 'shop-purchase' — иконка + название + цена (для покупки) - * - 'banner' — крупный текст - * - 'sign' — простой указатель - * - * Пример (4 таблички-апгрейды): - * const refs = ['vis','range','saws','sprink'].map((kind, i) => { - * return game.scene.spawn('billboard', { - * x: -6 + i*4, y: 3, z: 5, - * template: 'shop-item', - * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2', - * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] }, - * }); - * }); - * game.billboard.onClick(refs[0], 'buy', () => { - * game.ui.showText('Куплено!'); - * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' }); - * }); - */ - billboard: { - /** - * Полная замена контента таблички. Если пресет тот же — мгновенно - * перерисует. Если template другой — пересоздаст текстуру. - * ref — string-ref из game.scene.spawn() или game.scene.findOne() - * opts — { template?, face?, content?, elements? } - */ - set(ref, opts) { - const refStr = _normRef(ref); - if (!refStr || typeof opts !== 'object' || opts == null) return; - _send('billboard.set', { ref: refStr, ...opts }); - }, - /** - * Частичное обновление таблички. - * Две формы: - * 1) update(ref, patch) - * patch — частичный content: { sub, price, title, icon, gradient } - * Применяется к content пресета (shop-item/banner/sign). - * 2) update(ref, elementId, patch) - * Обновляет конкретный элемент по id (только для template:'card' - * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). - * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже - * работают как ключи content. - */ - update(ref, secondArg, thirdArg) { - const refStr = _normRef(ref); - if (!refStr) return; - // 3-аргументная форма: update(ref, elementId, patch) - if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { - _send('billboard.update', { - ref: refStr, - elementId: secondArg, - patch: thirdArg, - }); - return; - } - // 2-аргументная форма: update(ref, patch) - if (typeof secondArg === 'object' && secondArg !== null) { - _send('billboard.update', { ref: refStr, patch: secondArg }); - } - }, - /** - * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; - * в кастомных elements — id из элемента kind='button'). - * ref — string-ref - * buttonId — id кнопки (по умолчанию 'buy') - * fn — () => void - */ - onClick(ref, buttonId, fn) { - if (typeof fn !== 'function') { - fn = buttonId; - buttonId = 'buy'; - } - // Принудительная нормализация ref в plain-string: Instance-Proxy - // не сериализуется через postMessage (DataCloneError). - const refStr = _normRef(ref); - if (!refStr || typeof fn !== 'function') return; - const bid = String(buttonId || 'buy'); - const key = refStr + ':' + bid; - if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; - _billboardClickHandlers[key].push(fn); - _send('billboard.onClick', { ref: refStr, buttonId: bid }); - }, - }, - /** Окружение: небо, туман, время суток. */ - environment: { - /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ - setSkyColor(color) { - if (typeof color !== 'string') return; - _send('environment.setSkyColor', { color }); - }, - /** Установить туман: {enabled, color, density}. */ - setFog(opts) { - if (typeof opts !== 'object' || !opts) return; - _send('environment.setFog', opts); - }, - /** Установить время суток (часы, 0..24). */ - setTimeOfDay(hours) { - const h = Number(hours); - if (!Number.isFinite(h)) return; - _send('environment.setTimeOfDay', { hours: h }); - }, - }, - /** - * Управление режимами ввода — курсор и камера. - * В режиме 'ui' мышь работает как обычный курсор (как в браузере), - * не вращает камеру. Нужно для меню/инвентарей. - */ - input: { - /** - * Установить cursor-режим: 'ui' = курсор-как-в-браузере, - * 'game' = pointer-lock (мышь крутит камеру). - */ - setCursorMode(mode) { - if (mode !== 'ui' && mode !== 'game') return; - _send('input.setCursorMode', { mode }); - }, - /** - * Подписаться на движение мыши в UI-режиме. - * fn(x, y) — нормализованные координаты [0..1] относительно канваса. - */ - onMouseMove(fn) { - if (typeof fn !== 'function') return; - _mouseMoveHandlers.push(fn); - }, - /** Зажатие ЛКМ в UI-режиме. fn(x, y). */ - onMouseDown(fn) { - if (typeof fn !== 'function') return; - _mouseDownHandlers.push(fn); - }, - /** Отпускание ЛКМ. fn(x, y). */ - onMouseUp(fn) { - if (typeof fn !== 'function') return; - _mouseUpHandlers.push(fn); - }, - }, - /** - * Управление приложением целиком: переходы между страницами и т.д. - */ - app: { - /** Выйти из проекта на страницу ленты Kubikon-игр. */ - exit() { - _send('app.exit', {}); - }, - /** Перейти на произвольный URL (внутри сайта). */ - navigate(url) { - if (typeof url !== 'string' || !url) return; - _send('app.navigate', { url }); - }, - }, - /** - * УНИВЕРСАЛЬНОЕ хранилище сохранений (game saves). - * Любая игра может хранить произвольный JSON-стейт игрока. Под каждую - * игру таблицу создавать не нужно — всё через эти эндпоинты. - * - * namespace — строка типа 'progress', 'stats', 'inventory'. Под каждый - * одна запись на (project, user). Макс 20 namespace. - * data — произвольный объект JSON, до 50KB. - * - * Примеры: - * game.save.set('progress', { level: 3, gold: 250 }); - * game.save.get('progress', fn(data) {...}); - * game.save.merge('progress', { increment: { attempts: 1 } }); - * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); - */ - save: { - /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ - get(namespace, fn) { - if (typeof namespace !== 'string' || !namespace) return; - const reqId = 'sg_get_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.get', { reqId, namespace }); - }, - /** Прочитать ВСЕ сохранения юзера. fn(allNamespaces) — { ns1: data, ns2: data }. */ - getAll(fn) { - const reqId = 'sg_all_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.getAll', { reqId }); - }, - /** Записать (полная замена). data — объект/массив. */ - set(namespace, data) { - if (typeof namespace !== 'string' || !namespace) return; - if (data === undefined || data === null) return; - _send('save.set', { namespace, data }); - }, - /** Слияние с существующим. opts: - * { patch: {...}, increment: { key: delta }, max: { key: value } } - * patch — ключи копируются поверх - * increment — атомарный +=delta (нужно для счётчиков, не теряются - * данные если игрок играет с двух устройств) - * max — новое значение пишется только если оно больше старого */ - merge(namespace, opts) { - if (typeof namespace !== 'string' || !namespace) return; - if (!opts || typeof opts !== 'object') return; - _send('save.merge', { - namespace, - patch: opts.patch || {}, - increment: opts.increment || {}, - max: opts.max || {}, - }); - }, - /** Шорткат: атомарный +1 к счётчику. */ - increment(namespace, key, delta) { - if (typeof namespace !== 'string' || typeof key !== 'string') return; - const d = Number(delta); - const inc = {}; - inc[key] = Number.isFinite(d) ? d : 1; - _send('save.merge', { namespace, patch: {}, increment: inc, max: {} }); - }, - /** Лидерборд по ключу. order='asc' (меньше=лучше) | 'desc' (больше=лучше). - * fn(entries) — массив { rank, user_id, username, value }. */ - leaderboard(namespace, key, order, fn) { - if (typeof namespace !== 'string' || typeof key !== 'string') return; - const reqId = 'sg_lb_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.leaderboard', { - reqId, namespace, key, - order: order === 'asc' ? 'asc' : 'desc', - }); - }, - }, - log(...args) { - const parts = args.map(a => { - if (typeof a === 'string') return a; - if (typeof a === 'number' || typeof a === 'boolean') return String(a); - if (a == null) return String(a); - try { return JSON.stringify(a); } catch (e) { return '[object]'; } - }); - _send('log', { level: 'info', text: parts.join(' ') }); - }, - - /** - * Случайное число. - * random() → 0..1 - * random(max) → 0..max - * random(min, max) → min..max - * random(min, max, true) → целое min..max включительно - */ - random(min, max, integer) { - if (min === undefined) return Math.random(); - if (max === undefined) { max = min; min = 0; } - const a = Number(min), b = Number(max); - if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; - if (integer) { - const lo = Math.ceil(Math.min(a, b)); - const hi = Math.floor(Math.max(a, b)); - return Math.floor(Math.random() * (hi - lo + 1)) + lo; - } - return a + Math.random() * (b - a); - }, - - /** - * Расстояние между двумя точками или объектами. - * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). - */ - distance(a, b) { - const pa = _resolveToPos(a); - const pb = _resolveToPos(b); - if (!pa || !pb) return Infinity; - const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z; - return Math.sqrt(dx * dx + dy * dy + dz * dz); - }, - - /** - * Отправить именованное сообщение всем скриптам (включая себя). - * Используется для общения между скриптами в разных sandbox'ах. - * game.broadcast('checkpoint', { num: 2 }); - */ - broadcast(name, data) { - if (typeof name !== 'string' || !name) return; - _send('broadcast', { name, data: data == null ? null : data }); - }, - - /** - * Подключить скрипт-модуль и получить его exports. - * Модуль — другой скрипт проекта; в нём пишут в объект exports: - * // скрипт "math_utils": - * exports.add = (a, b) => a + b; - * // обычный скрипт: - * const m = game.require('math_utils'); - * game.log(m.add(2, 3)); // 5 - * Модуль исполняется один раз, дальше отдаётся из кеша. - */ - require(name) { - if (typeof name !== 'string' || !name) return null; - if (_moduleCache[name]) return _moduleCache[name]; - const code = _moduleCode[name]; - if (code == null) { - _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name }); - return null; - } - try { - const exportsObj = {}; - // модуль видит game и exports; повторный require внутри модуля тоже работает - const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code); - moduleFn(game, exportsObj); - _moduleCache[name] = exportsObj; - return exportsObj; - } catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err), - }); - return null; - } - }, - - /** - * Подписаться на сообщение. - * game.onMessage('checkpoint', (data) => { ... }); - */ - onMessage(name, fn) { - if (typeof name !== 'string' || !name) return; - if (typeof fn !== 'function') return; - (_messageHandlers[name] = _messageHandlers[name] || []).push(fn); - }, - - /** Зажать значение между min и max. */ - clamp(value, min, max) { - const v = Number(value); - const lo = Number(min), hi = Number(max); - if (!Number.isFinite(v)) return 0; - if (v < lo) return lo; - if (v > hi) return hi; - return v; - }, - - /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */ - lerp(a, b, t) { - const na = Number(a), nb = Number(b), nt = Number(t); - return na + (nb - na) * nt; - }, -}; - -/** - * Пересечение луча с AABB (в локальных координатах бокса, центр в 0). - * Возвращает расстояние t до точки входа или null если не пересекает. - * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.), - * (hw,hh,hd)=полуразмеры бокса. - */ -function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) { - let tmin = -Infinity, tmax = Infinity; - const axes = [ - [ox, dx, hw], [oy, dy, hh], [oz, dz, hd], - ]; - for (const [o, d, h] of axes) { - if (Math.abs(d) < 1e-9) { - // луч параллелен слою — мимо если начало вне границ - if (o < -h || o > h) return null; - } else { - let t1 = (-h - o) / d; - let t2 = (h - o) / d; - if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; } - if (t1 > tmin) tmin = t1; - if (t2 < tmax) tmax = t2; - if (tmin > tmax) return null; - } - } - // tmin<0 значит начало внутри бокса — берём 0 - return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null); -} - -/** Резолв позиции из {x,y,z} или ref-строки. */ -function _resolveToPos(arg) { - if (!arg) return null; - if (typeof arg === 'string') { - for (const b of _sceneIndex.blocks) if (b.ref === arg) return { x: b.x, y: b.y, z: b.z }; - for (const m of _sceneIndex.models) if (m.ref === arg) return { x: m.x, y: m.y, z: m.z }; - for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z }; - return null; - } - if (typeof arg === 'object' && Number.isFinite(arg.x)) { - return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 }; - } - return null; -} - -// === Обработчики сообщений из main === -self.onmessage = (e) => { - const { cmd, payload } = e.data || {}; - if (cmd === 'init') { - // payload: { code, target?, selfPosition?, modules? } - if (payload && payload.target) { - _target = payload.target; - if (payload.selfPosition) _selfPosition = payload.selfPosition; - _selfApi = _buildSelfApi(); - } - // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require - if (payload && payload.modules && typeof payload.modules === 'object') { - _moduleCode = payload.modules; - } - // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, - // чтобы findOne()/find() работали в синхронном теле скрипта на старте - // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). - if (payload && payload.initialScene && typeof payload.initialScene === 'object') { - const s = payload.initialScene; - _sceneIndex = { - blocks: s.blocks || [], - models: s.models || [], - primitives: s.primitives || [], - }; - } - try { - // exports передаём всегда — скрипт может быть и модулем (пишет в - // exports), и обычным скриптом (игнорирует его). Без этого - // скрипт-модуль падает с 'exports is not defined' при прямом запуске. - const exportsObj = {}; - const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); - userFn(game, exportsObj); - _send('ready', null); - } catch (err) { - _send('log', { level: 'error', text: 'Ошибка скрипта: ' + (err && err.message ? err.message : err) }); - _send('ready', null); - } - } else if (cmd === 'tick') { - const dt = payload && typeof payload.dt === 'number' ? payload.dt : 0; - if (payload && payload.player) { - const pp = payload.player; - if (pp.position) _playerState.position = pp.position; - if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; - if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; - if (pp.forward) _playerState.forward = pp.forward; - if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; - if (typeof pp.hp === 'number') _playerState.hp = pp.hp; - if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; - // Кубикон Dash: направление гравитации (+1 / -1). - if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; - if (typeof pp.state === 'string') _playerState.state = pp.state; - if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; - } - if (payload && payload.selfPosition) { - _selfPosition = payload.selfPosition; - } - if (payload && Array.isArray(payload.mobs)) { - _mobs = payload.mobs; - } - if (payload && Array.isArray(payload.npcs)) { - _npcs = payload.npcs; - } - if (payload && payload.inventory && typeof payload.inventory === 'object') { - _inventory = payload.inventory; - } - if (payload && payload.players && typeof payload.players === 'object') { - _players = payload.players; - } - if (payload && payload.roomState && typeof payload.roomState === 'object') { - _roomState = payload.roomState; - } - if (payload && Array.isArray(payload.teams)) { - _teams = payload.teams; - } - for (const fn of _tickHandlers) { - _safeCall(fn, dt, 'onTick'); - } - // Таймеры game.after / game.every — копим dt, срабатываем при достижении delay. - // Итерируем по копии: callback может вызвать game.after/cancel и изменить _timers. - if (_timers.length > 0 && dt > 0) { - const due = []; - for (const t of _timers) { - t.elapsed += dt; - if (t.elapsed >= t.delay) due.push(t); - } - for (const t of due) { - if (t.repeat) { - // отнимаем delay (не сбрасываем в 0) — равномерный интервал без дрейфа - t.elapsed -= t.delay; - } else { - const i = _timers.indexOf(t); - if (i >= 0) _timers.splice(i, 1); - } - _safeCall(t.fn, undefined, t.repeat ? 'every' : 'after'); - } - } - } else if (cmd === 'state') { - if (payload && payload.player) { - const pp = payload.player; - if (pp.position) _playerState.position = pp.position; - if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; - if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; - if (pp.forward) _playerState.forward = pp.forward; - if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; - if (typeof pp.hp === 'number') _playerState.hp = pp.hp; - if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; - // Кубикон Dash: направление гравитации (+1 / -1). - if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; - if (typeof pp.state === 'string') _playerState.state = pp.state; - if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; - } - if (payload && payload.selfPosition) { - _selfPosition = payload.selfPosition; - } - if (payload && Array.isArray(payload.mobs)) { - _mobs = payload.mobs; - } - if (payload && Array.isArray(payload.npcs)) { - _npcs = payload.npcs; - } - if (payload && payload.inventory && typeof payload.inventory === 'object') { - _inventory = payload.inventory; - } - if (payload && payload.players && typeof payload.players === 'object') { - _players = payload.players; - } - if (payload && payload.roomState && typeof payload.roomState === 'object') { - _roomState = payload.roomState; - } - if (payload && Array.isArray(payload.teams)) { - _teams = payload.teams; - } - } else if (cmd === 'event') { - // payload: { type, ...data } - const t = payload?.type; - if (t === 'click') { - // self.onClick — только если есть target и target совпал - for (const fn of _selfClickHandlers) _safeCall(fn, payload, 'self.onClick'); - } else if (t === 'touch') { - for (const fn of _selfTouchHandlers) _safeCall(fn, payload, 'self.onTouch'); - } else if (t === 'untouch') { - for (const fn of _selfUntouchHandlers) _safeCall(fn, payload, 'self.onUntouch'); - } else if (t === 'interact') { - for (const fn of _selfInteractHandlers) _safeCall(fn, payload, 'self.onInteract'); - } - } else if (cmd === 'globalEvent') { - // payload: { type, ...data } — глобальные события (всем sandbox'ам) - const t = payload?.type; - if (t === 'click') { - for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); - } else if (t === 'mouseMove') { - for (const fn of _mouseMoveHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'mouseDown') { - for (const fn of _mouseDownHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'mouseUp') { - for (const fn of _mouseUpHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'playerTouch') { - for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); - } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { - // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). - const b = _instTouchHandlers.get(payload && payload.ref); - if (b) { - const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; - for (const fn of list) _safeCall(fn, payload, 'inst.' + t); - } - } else if (t === 'hpChange') { - for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); - } else if (t === 'mobKilled') { - for (const fn of _mobKilledHandlers) _safeCall(fn, payload, 'onMobKilled'); - } else if (t === 'npcDeath') { - // payload: { npcId, position } - const npcId = payload.npcId; - const ev = { id: npcId, position: payload.position }; - // Глобальные подписчики game.onNpcDeath(fn). - for (const fn of _globalNpcDeathHandlers) _safeCall(fn, ev, 'onNpcDeath'); - // Адресные подписки npc.onDeath — по числовому id ИЛИ по - // локальному ref, который при спавне привязали к этому id. - const keys = [String(npcId)]; - for (const [lref, real] of Object.entries(_npcLocalToReal)) { - if (real === npcId) keys.push(lref); - } - for (const key of keys) { - const arr = _npcDeathHandlers[key] || []; - for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); - } - } else if (t === 'toolUse') { - // payload: { tool: {kind, modelTypeId, name}, point, target } - const ev = { - tool: payload.tool || null, - point: payload.point || null, - target: payload.target || null, - }; - for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); - } else if (t === 'cutsceneDone') { - // Катсцена камеры завершилась (Фаза 5.7). - for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); - } else if (t === 'playerJoin') { - // payload: { sessionId, name } - for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); - } else if (t === 'playerLeave') { - for (const fn of _playerLeaveHandlers) _safeCall(fn, payload, 'onPlayerLeave'); - } else if (t === 'roomChange') { - // payload: { key, value } — изменилось общее состояние комнаты. - const arr = _roomChangeHandlers[payload.key] || []; - for (const fn of arr) _safeCall(fn, payload.value, 'room.onChange:' + payload.key); - } else if (t === 'mpMessage') { - // payload: { from, name, data } — адресное сообщение. - const arr = _mpMessageHandlers[payload.name] || []; - for (const fn of arr) { - _safeCall(fn, { from: payload.from, data: payload.data }, - 'onMessage:' + payload.name); - } - } else if (t === 'playerDied') { - for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied'); - } else if (t === 'playerJump') { - for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump'); - } else if (t === 'playerLand') { - for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand'); - } else if (t === 'keydown') { - const key = String(payload.key || '').toLowerCase(); - const arr = _globalKeyDownHandlers[key] || []; - for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key); - const wild = _globalKeyDownHandlers['*'] || []; - for (const fn of wild) _safeCall(fn, payload, 'onKey(*)'); - } else if (t === 'keyup') { - const key = String(payload.key || '').toLowerCase(); - const arr = _globalKeyUpHandlers[key] || []; - for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key); - const wild = _globalKeyUpHandlers['*'] || []; - for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)'); - } else if (t === 'message') { - const name = String(payload.name || ''); - const arr = _messageHandlers[name] || []; - for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); - } else if (t === 'guiClick') { - const id = String(payload.id || ''); - const localId = payload.localId != null ? String(payload.localId) : null; - // Собираем handlers по id, по локальному ref и по имени элемента — - // скрипт мог подписаться любым из этих ключей. - // _matched защищает от двойного вызова если несколько ключей ведут - // к одному и тому же массиву handlers. - const _matched = new Set(); - for (const key of _guiHandlerKeys(id, localId)) { - const arr = _guiClickHandlers[key]; - if (!arr || _matched.has(arr)) continue; - _matched.add(arr); - for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); - } - } else if (t === 'guiSubmit') { - const id = String(payload.id || ''); - const localId = payload.localId != null ? String(payload.localId) : null; - const val = payload.value != null ? String(payload.value) : ''; - const _matched = new Set(); - for (const key of _guiHandlerKeys(id, localId)) { - const arr = _guiSubmitHandlers[key]; - if (!arr || _matched.has(arr)) continue; - _matched.add(arr); - for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); - } - } else if (t === 'billboardClick') { - // payload: { ref, button } — клик по кнопке 3D-таблички. - // Ищем handlers и по реальному ref (primitive:NN), и по локальному - // ref если такой есть (на случай если скрипт подписался по - // локальному ref от scene.spawn). - const realRef = String(payload.ref || ''); - const button = String(payload.button || 'buy'); - const tryKeys = [realRef + ':' + button]; - // Если есть локальный ref, ведущий к этому real — тоже попробуем - // (скрипт мог подписаться на ref сразу после game.scene.spawn, - // когда ref был ещё локальным _local_N). - for (const [local, real] of Object.entries(_spawnLocalToReal || {})) { - if (real === realRef) tryKeys.push(local + ':' + button); - } - for (const key of tryKeys) { - const arr = _billboardClickHandlers[key] || []; - for (const fn of arr) _safeCall(fn, { ref: realRef, button }, - 'billboard.onClick:' + key); - } - } else if (t === 'modalOpened') { - // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту - // локальный id (чтобы он мог его сохранить и звать close/update); здесь - // запоминаем маппинг local→real, иначе close(m) уходит с локальным id - // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). - try { - const mm = (typeof game !== 'undefined') && game.modal; - if (mm && payload && payload.replyId) { - const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); - if (Number.isFinite(localId) && payload.modalId != null) { - mm._localToReal.set(localId, payload.modalId); - mm._isOpenLocal = true; - } - } - } catch (e) {} - } else if (t === 'modalClosed') { - // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. - try { - const mm = (typeof game !== 'undefined') && game.modal; - if (mm) { - mm._isOpenLocal = false; - const cbs = mm._onCloseFns || []; - for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); - } - } catch (e) {} - } else if (t === 'skinChanged') { - // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. - const slug = payload && payload.slug; - if (slug) { - _currentSkin = slug; - for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); - } - } else if (t === 'skinUnlocked') { - const slug = payload && payload.slug; - if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - } - } else if (cmd === 'sceneSnapshot') { - // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } - if (payload) { - _sceneIndex = { - blocks: payload.blocks || [], - models: payload.models || [], - primitives: payload.primitives || [], - }; - // детект дельт и эмит events для Instance (если кто-то подписан). - try { _detectSnapshotDeltas(); } catch (e) {} - } - } else if (cmd === 'guiSnapshot') { - // payload: массив всех GUI-элементов (для game.gui.find/get/all) - _guiIndex = Array.isArray(payload) ? payload : []; - } else if (cmd === 'skinsSnapshot') { - // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } - if (payload && typeof payload === 'object') { - _skinsIndex = Array.isArray(payload.all) ? payload.all : []; - _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; - _currentSkin = payload.current || _currentSkin; - if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; - } - } else if (cmd === 'dataSnapshot') { - // payload: { ref: { key: value } } — атрибуты всех объектов - _dataIndex = payload && typeof payload === 'object' ? payload : {}; - } else if (cmd === 'terrainHeightmap') { - // payload: { origin:{x,z}, step, cols, rows, heights:[] } - // Карта высот гладкого ландшафта для game.scene.surfaceY. - _terrainHM = payload || null; - } else if (cmd === 'saveResponse') { - // payload: { reqId, result } - const reqId = payload && payload.reqId; - const cb = reqId && _saveCallbacks[reqId]; - if (cb) { - delete _saveCallbacks[reqId]; - try { cb(payload.result); } catch (e) {} - } - } else if (cmd === 'economyResponse') { - // payload: { reqId, result } - const reqId = payload && payload.reqId; - const cb = reqId && _economyCallbacks[reqId]; - if (cb) { - delete _economyCallbacks[reqId]; - try { cb(payload.result); } catch (e) {} - } - } else if (cmd === 'tweenDone') { - // payload: { tweenId } — твин доиграл, зовём onDone - const tid = payload && payload.tweenId; - const cb = tid != null && _tweenCallbacks[tid]; - if (cb) { - delete _tweenCallbacks[tid]; - _safeCall(cb, undefined, 'tween.onDone'); - } - } else if (cmd === 'npcSpawned') { - // payload: { localRef, npcId } — async-спавн NPC завершён. - // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал. - if (payload && payload.localRef != null) { - _npcLocalToReal[payload.localRef] = payload.npcId; - } - } else if (cmd === 'spawnResolved') { - // payload: { localRef, realRef } — scene.spawn создал объект. - // Запоминаем маппинг для getPosition и т.п. - if (payload && payload.localRef && payload.realRef) { - _spawnLocalToReal[payload.localRef] = payload.realRef; - } - } else if (cmd === 'stop') { - _tickHandlers = []; - _timers = []; - _selfClickHandlers = []; - _selfTouchHandlers = []; - _selfUntouchHandlers = []; - _selfInteractHandlers = []; - _instTouchHandlers.clear(); - _globalKeyDownHandlers = {}; - _globalKeyUpHandlers = {}; - _globalClickHandlers = []; - _globalTouchHandlers = []; - _mouseMoveHandlers = []; - _mouseDownHandlers = []; - _mouseUpHandlers = []; - _mobKilledHandlers = []; - _hpChangeHandlers = []; - _playerDiedHandlers = []; - _playerJumpHandlers = []; - _playerLandHandlers = []; - _messageHandlers = {}; - _guiClickHandlers = {}; - _guiSubmitHandlers = {}; - _npcDeathHandlers = {}; - _globalNpcDeathHandlers = []; - _npcLocalToReal = {}; - _spawnLocalToReal = {}; - _npcs = []; - _toolUseHandlers = []; - _inventory = { slots: [], activeIndex: 0 }; - _players = { me: null, list: [] }; - _roomState = {}; - _playerJoinHandlers = []; - _playerLeaveHandlers = []; - _cutsceneDoneHandlers = []; - _mpMessageHandlers = {}; - _roomChangeHandlers = {}; - _teams = []; - _constraintRefSeq = 0; - _fxRefSeq = 0; - _soundRefSeq = 0; - } -}; - -_send('boot', null); -`; - -/** - * Создаёт URL Worker-кода для new Worker(url). - */ -export function getWorkerSourceUrl() { - const blob = new Blob([SOURCE], { type: 'application/javascript' }); - return URL.createObjectURL(blob); -} +/** + * ScriptSandboxWorker.js — код, исполняющийся внутри Web Worker. + * + * НЕ импортируется напрямую через ES-import. Загружается через blob-URL, + * созданный в ScriptSandbox.js. + * + * Архитектура: пользовательский скрипт получает ТОЛЬКО объект `game`. + * Любая операция (двигать игрока, лог) превращается в команду + * postMessage в main thread. Main thread исполняет на Babylon-сцене и + * присылает обратно state-update'ы. + * + * API (этап 2.3.1): + * game.player.position — {x, y, z}, обновляется main thread'ом + * game.player.teleport(x, y, z) + * game.onTick(fn) — fn(dt) каждый кадр + * game.log(...args) — лог в Console + * + * game.self — {kind, ref, position} — объект-носитель + * (только для скриптов с target) + * game.self.onClick(fn) — fn(event) при клике по объекту в Play + * game.self.onTouch(fn) — fn(event) когда игрок касается объекта + * game.self.move(x, y, z) — переместить объект (для моделей/примитивов) + * game.self.delete() — удалить объект-носитель + */ + +const SOURCE = ` +"use strict"; + +// === Внутреннее состояние Worker'а === +let _tickHandlers = []; +let _playerState = { + position: { x: 0, y: 0, z: 0 }, + yaw: 0, + pitch: 0, + forward: { x: 0, y: 0, z: 1 }, // нормализованный вектор взгляда + crosshair: 'none', // 'none' | 'dot' | 'cross' | 'circle' + hp: 100, + maxHp: 100, + state: 'ground', // 'ground' | 'air' | 'water' + keys: {}, // { 'w': true, 'space': true } — зажатые сейчас клавиши +}; +// target скрипта (если есть) — пришёл при init +let _target = null; +// Зеркало position объекта-носителя (если target.kind != null) +let _selfPosition = { x: 0, y: 0, z: 0 }; +// Снимок живых мобов — обновляется каждый tick из main thread +let _mobs = []; +// Снимок NPC (Фаза 4.1) — обновляется каждый tick из main thread. +// Каждый: { id, name, x, y, z, hp, maxHp, mode }. +let _npcs = []; +// Счётчик локальных ref'ов для NPC, заспавненных скриптом. +let _npcRefSeq = 0; +// Маппинг локальный ref ('npc:_local_N') → реальный числовой npcId. +// Заполняется когда main thread присылает 'npcSpawned' после async-спавна. +let _npcLocalToReal = {}; +// Маппинг локальный ref scene.spawn ('primitive:_local_N') → реальный +// ('primitive:N'). main thread шлёт 'spawnResolved' после создания. +// Нужно чтобы getPosition и др. находили заспавненный объект в _sceneIndex. +let _spawnLocalToReal = {}; +// Подписки npc.onDeath: ключ = локальный ref ИЛИ строка-id → [fn]. +let _npcDeathHandlers = {}; +// Глобальные подписки game.onNpcDeath(fn). +let _globalNpcDeathHandlers = []; +// Снимок инвентаря (Фаза 4.2): { slots: [...], activeIndex }. +let _inventory = { slots: [], activeIndex: 0 }; +// Подписки game.player.onToolUse(fn). +let _toolUseHandlers = []; +// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove. +let _placeOnPlaceHandlers = []; +let _placeOnCancelHandlers = []; +let _placeOnMoveHandlers = []; +let _invUiSlotClickHandlers = []; +// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. +let _players = { me: null, list: [] }; +// Общее состояние комнаты game.room.get/set — зеркало из main thread. +let _roomState = {}; +// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). +let _playerJoinHandlers = []; +let _playerLeaveHandlers = []; +// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). +let _cutsceneDoneHandlers = []; +let _vehicleEnterHandlers = []; // задача 14 +let _vehicleExitHandlers = []; +let _mpMessageHandlers = {}; // name → [fn] +// Подписки game.room.onChange(key, fn): key → [fn]. +let _roomChangeHandlers = {}; +// Команды (Фаза 4.4): массив { name, color } — зеркало из main thread. +let _teams = []; +// Счётчик локальных ref'ов для связей-constraints (Фаза 5). +let _constraintRefSeq = 0; +// Счётчик локальных ref'ов для лучей/следов (Фаза 5.2). +let _fxRefSeq = 0; +// Счётчик локальных ref'ов для звуков (Фаза 5.5). +let _soundRefSeq = 0; +// Подписки на события объекта (self.*) +let _selfClickHandlers = []; +let _selfTouchHandlers = []; +let _selfUntouchHandlers = []; +// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) +let _selfInteractHandlers = []; +// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). +// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих +// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. +const _instTouchHandlers = new Map(); +function _instHandlerBucket(ref) { + let b = _instTouchHandlers.get(ref); + if (!b) { b = { touch: [], untouch: [], click: [], interact: [] }; _instTouchHandlers.set(ref, b); } + return b; +} +// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') +let _guiIndex = []; +// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). +// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. +// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. +let _skinsIndex = []; +let _unlockedSkins = []; +let _currentSkin = null; +let _skinChangeHandlers = []; +let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) +// Подписки game.gui.onClick(id, fn) +let _guiClickHandlers = {}; +// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) +let _guiSubmitHandlers = {}; +// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке +// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка +// из game.scene.spawn() или game.scene.findOne() в формате +// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime), +// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. +let _billboardClickHandlers = {}; +// Для 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 + имя элемента (скрипт +// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). +function _guiHandlerKeys(id, localId) { + const keys = [id]; + // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог + // подписаться по нему, если не задавал явный id. + if (localId != null && localId !== id) keys.push(localId); + // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). + const el = _guiIndex.find(g => g.id === id); + if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); + return keys; +} + +// Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с +// числовыми id, локальный ref → id через _npcLocalToReal. +function _findNpcState(localRef) { + const id = _npcLocalToReal[localRef]; + if (id == null) return null; + return _npcs.find(n => n.id === id) || null; +} + +// Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref — +// main thread резолвит его в реальный npcId. +function _makeNpcProxy(ref) { + return { + get ref() { return ref; }, + /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */ + get position() { + const st = _findNpcState(ref); + return st ? { x: st.x, y: st.y, z: st.z } : null; + }, + /** Текущее HP или null. */ + get hp() { + const st = _findNpcState(ref); + return st ? st.hp : null; + }, + /** Имя NPC или null. */ + get name() { + const st = _findNpcState(ref); + return st ? st.name : null; + }, + /** Идти в точку (XZ). */ + moveTo(x, z) { + _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 }); + }, + /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход + * в кат-сцене → быстрая погоня в игре. */ + setSpeed(speed) { + const s = Number(speed); + if (Number.isFinite(s) && s > 0) { + _send('npc.setSpeed', { ref, speed: s }); + } + }, + /** Следовать за объектом: 'player' или ref объекта сцены. */ + follow(target) { + _send('npc.follow', { ref, target }); + }, + /** Остановиться. */ + stop() { + _send('npc.stop', { ref }); + }, + /** Реплика над головой на duration секунд (по умолчанию 3). */ + say(text, duration) { + _send('npc.say', { + ref, + text: String(text == null ? '' : text), + duration: Number.isFinite(Number(duration)) ? Number(duration) : 3, + }); + }, + /** Нанести урон NPC. */ + damage(amount) { + _send('npc.damage', { ref, amount: Number(amount) || 0 }); + }, + /** Убрать NPC со сцены. */ + remove() { + _send('npc.remove', { ref }); + }, + /** Колбэк при гибели этого NPC. fn получает {id, position}. */ + onDeath(fn) { + if (typeof fn === 'function') { + (_npcDeathHandlers[ref] = _npcDeathHandlers[ref] || []).push(fn); + } + }, + }; +} +// Глобальные подписки +let _globalKeyDownHandlers = {}; // { 'w': [fn, fn], ... } — ключи нормализованы в lower-case +let _globalKeyUpHandlers = {}; +let _globalClickHandlers = []; +let _globalTouchHandlers = []; +// Колбэки на движение мыши в UI-режиме (game.input.onMouseMove) +let _mouseMoveHandlers = []; +let _mouseDownHandlers = []; +let _mouseUpHandlers = []; +// Колбэк на убийство моба (зомби и т.п.) — fn({mobType, position}) +let _mobKilledHandlers = []; + +// SaveGame API: счётчик request-id и map колбэков по reqId +let _saveReqSeq = 0; +const _saveCallbacks = {}; +// Economy API (GD-награды): request-id → callback +let _economyReqSeq = 0; +const _economyCallbacks = {}; +// Колбэк на изменение HP игрока (для логирования урона/смерти из скриптов) +let _hpChangeHandlers = []; +// Подписки на события игрока: смерть / прыжок / приземление +let _playerDiedHandlers = []; +let _playerJumpHandlers = []; +let _playerLandHandlers = []; +// Broadcast между sandbox'ами: имя сообщения → массив обработчиков +let _messageHandlers = {}; +// Счётчик для локальных ref'ов спавненных через game.scene.spawn +let _localRefSeq = 0; + +// Твины (game.tween): id → callback onDone. Сами твины крутит main-thread +// (GameRuntime), сюда возвращается только событие завершения по reqId. +let _tweenSeq = 0; +const _tweenCallbacks = {}; + +// Таймеры (game.after / game.every). Каждый: { id, fn, delay, elapsed, repeat } +// repeat=false → after (один раз), repeat=true → every (циклично). +// Тикаются в обработчике cmd='tick' по накоплению dt. +let _timers = []; +let _timerSeq = 0; +// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз +// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. +// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } +let _sceneIndex = { blocks: [], models: [], primitives: [] }; + +// Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). +// Приходит один раз через cmd='terrainHeightmap'. Формат: +// { origin:{x,z}, step, cols, rows, heights:number[] } +let _terrainHM = null; + +// Атрибуты объектов (game.scene.setData/getData). Зеркало из main thread. +// Формат: { ref: { key: value, ... } }. Синхронизируется через cmd='dataSnapshot'. +// getData читает отсюда синхронно, setData шлёт команду в main. +let _dataIndex = {}; + +// ═══════════════════════════════════════════════════════════════════════ +// Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают +// Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, +// position/name/parent/children и т.д.). Coerces в строку-ref через +// Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и +// т.п., принимавший строковый ref) продолжает работать без изменений. +const _instCache = new Map(); // ref → Instance proxy +const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } +const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot + +function _safeCall2(fn, args, where) { + try { fn.apply(null, args); } + catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), + }); + } +} + +function _getOrCreateInstance(ref, kindHint) { + if (!ref || typeof ref !== 'string') return null; + if (_instCache.has(ref)) return _instCache.get(ref); + + const target = { + get ref() { return ref; }, + get kind() { + if (kindHint) return kindHint; + for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; + for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; + for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; + return 'unknown'; + }, + toString() { return ref; }, + }; + + const proxy = new Proxy(target, { + get(t, prop) { + if (prop === 'ref' || prop === 'kind') return t[prop]; + if (prop === 'toString') return t.toString; + if (prop === Symbol.toPrimitive) return () => ref; + if (prop === 'valueOf') return () => ref; + + // === Геттеры из snapshot === + if (prop === 'position') { + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; + } + return null; + } + if (prop === 'name') { + for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return o.name || null; + } + return null; + } + if (prop === 'parent') { + const data = _dataIndex[ref]; + const parentRef = data && data.__parent; + return parentRef ? _getOrCreateInstance(parentRef) : null; + } + if (prop === 'children') { + const data = _dataIndex[ref]; + const ids = (data && data.__children) || []; + return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); + } + if (prop === 'descendants') { + return () => { + const out = []; + const stack = [...((_dataIndex[ref] || {}).__children || [])]; + while (stack.length) { + const id = stack.pop(); + const inst = _getOrCreateInstance(id); + if (inst) { + out.push(inst); + const kids = (_dataIndex[id] || {}).__children || []; + for (const k of kids) stack.push(k); + } + } + return out; + }; + } + + // === События === + if (prop === 'changed') { + return { + connect(propName, fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.propChanged.push({ prop: propName, fn }); + }, + }; + } + if (prop === 'destroying') { + return { + connect(fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.destroying.push(fn); + }, + }; + } + + // === Методы === + if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); + if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); + if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); + if (prop === 'getAttribute') return (k) => { + const data = _dataIndex[ref]; + return data ? data[k] : undefined; + }; + if (prop === 'hasTag') return (t) => { + const data = _dataIndex[ref]; + return Array.isArray(data && data.__tags) && data.__tags.includes(t); + }; + if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); + if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); + if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); + if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); + if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); + if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); + + // === События касания/клика ПРОИЗВОЛЬНОГО объекта === + // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. + // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB + // объекта (inst.watchTouch) и шлёт instTouch на rising edge. + if (prop === 'onTouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).touch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onUntouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).untouch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onClick') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).click.push(fn); + _send('inst.watchClick', { ref }); + }; + if (prop === 'onInteract') return (fn, opts) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).interact.push(fn); + _send('inst.registerInteract', { + ref, + text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', + distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, + key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', + holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0, + }); + }; + + return undefined; + }, + set(t, prop, value) { + if (prop === 'name') { + _send('inst.set', { ref, prop: 'name', value: String(value) }); + return true; + } + if (prop === 'parent') { + const parentRef = value && typeof value === 'object' + ? (value.ref || value.toString()) + : (value || null); + _send('inst.setParent', { ref, parentRef }); + return true; + } + if (prop === 'position') { + if (value && typeof value === 'object') { + const x = Number(value.x), y = Number(value.y), z = Number(value.z); + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + _send('scene.move', { ref, x, y, z }); + } + } + return true; + } + if (prop === 'color') { + _send('scene.setColor', { ref, color: String(value) }); + return true; + } + if (prop === 'transparency' || prop === 'opacity') { + const v = Number(value); + if (Number.isFinite(v)) { + const op = prop === 'transparency' ? (1 - v) : v; + _send('scene.setOpacity', { ref, value: op }); + } + return true; + } + if (prop === 'visible') { + _send('scene.setVisible', { ref, visible: !!value }); + return true; + } + if (prop === 'canCollide') { + _send('scene.setCollide', { ref, collide: !!value }); + return true; + } + if (prop === 'material') { + _send('scene.setMaterial', { ref, name: String(value) }); + return true; + } + return true; + }, + }); + + _instCache.set(ref, proxy); + return proxy; +} + +/** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ +function _emitInstChange(ref, prop, newVal, oldVal) { + const evs = _instEvents.get(ref); + if (!evs) return; + for (const rec of evs.propChanged) { + if (rec.prop === prop) { + _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); + } + } +} + +/** Триггер destroying — объект больше не в snapshot. */ +function _emitInstDestroying(ref) { + const evs = _instEvents.get(ref); + if (evs) { + for (const fn of evs.destroying) { + _safeCall2(fn, [], 'inst.destroying'); + } + } + _instEvents.delete(ref); + _instCache.delete(ref); + _instLastValues.delete(ref); +} + +/** + * Сравнение нового snapshot со старым — детект дельт для событий. + * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. + */ +function _detectSnapshotDeltas() { + const live = new Set(); + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) { + live.add(o.ref); + if (!_instCache.has(o.ref)) continue; + const prev = _instLastValues.get(o.ref) || {}; + if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { + _emitInstChange(o.ref, 'position', + { x: o.x, y: o.y, z: o.z }, + { x: prev.x, y: prev.y, z: prev.z }); + } + if (prev.name !== undefined && prev.name !== o.name) { + _emitInstChange(o.ref, 'name', o.name, prev.name); + } + _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); + } + } + for (const ref of [..._instCache.keys()]) { + if (live.has(ref)) continue; + const inst = _instCache.get(ref); + if (inst && inst.kind === 'primitive') { + _emitInstDestroying(ref); + } + } +} + +// Модули (game.require). Код всех скриптов-модулей приходит при init. +// _moduleCode — { 'имя': 'код модуля' } +// _moduleCache — { 'имя': exports } — кеш исполненных модулей +let _moduleCode = {}; +let _moduleCache = {}; + +// Утилиты безопасной отправки в main +const _send = (cmd, payload) => { + try { postMessage({ cmd, payload }); } catch (e) {} +}; + +// Нормализация ref: строка → она сама; Instance-прокси → поле .ref; +// иначе null. Нужно чтобы billboard.set/update/onClick принимали и +// строковый ref ('primitive:NN'), и объект, у которого есть .ref. +function _normRef(ref) { + if (typeof ref === 'string') return ref || null; + if (ref && typeof ref === 'object') { + if (typeof ref.ref === 'string' && ref.ref) return ref.ref; + const s = String(ref); + return s && s !== '[object Object]' ? s : null; + } + return null; +} + +const _safeCall = (fn, arg, where) => { + try { fn(arg); } + catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), + }); + } +}; + +// Внутренний хелпер: запланировать удаление объекта через seconds секунд. +// Используется для lifetime в scene.spawn и для scene.deleteAfter. +const _scheduleDelete = (ref, seconds) => { + const s = Number(seconds); + if (typeof ref !== 'string' || !ref || !Number.isFinite(s) || s < 0) return; + _timers.push({ + id: ++_timerSeq, + fn: () => _send('scene.delete', { ref }), + delay: s, elapsed: 0, repeat: false, + }); +}; + +// === Публичное API game.* === + +// Объект self создаётся ПОСЛЕ получения init с target. +// Если target нет (глобальный скрипт), game.self === null. +let _selfApi = null; + +function _buildSelfApi() { + if (!_target) return null; + const isGui = _target.kind === 'gui'; + const api = { + get kind() { return _target.kind; }, + // ref — строка формата 'primitive:N' / 'model:N' / 'block:x,y,z', + // единая со scene.find/all и пригодная для scene.* / physics.* / tween. + get ref() { + const k = _target.kind; + if (k === 'primitive' || k === 'model' || k === 'userModel') { + const id = _target.id ?? _target.ref; + return id != null ? k + ':' + id : null; + } + if (k === 'block') { + const r = _target.ref || _target; + if (r && r.x != null) return 'block:' + r.x + ',' + r.y + ',' + r.z; + return null; + } + return _target.ref ?? _target.id ?? null; + }, + get position() { return { ..._selfPosition }; }, + /** + * Свойства GUI-элемента (только для скриптов с target.kind='gui'): + * game.self.props — текущие свойства (имя, текст, x/y/w/h, цвет...) + */ + get props() { + if (!isGui) return null; + const id = _target.id ?? _target.ref; + const found = _guiIndex.find(g => g.id === id); + return found ? { ...found } : null; + }, + onClick(fn) { + if (typeof fn === 'function') _selfClickHandlers.push(fn); + }, + onTouch(fn) { + if (typeof fn === 'function') _selfTouchHandlers.push(fn); + }, + /** Игрок ВЫШЕЛ из объекта (был внутри AABB и вышел). Полезно для триггер-зон. */ + onUntouch(fn) { + if (typeof fn === 'function') _selfUntouchHandlers.push(fn); + }, + /** + * Взаимодействие по клавише E. Когда игрок подходит близко к объекту — + * над объектом появляется подсказка «[E] ...», по нажатию E срабатывает fn. + * game.self.onInteract(() => { + * game.ui.showText('Дверь открыта!'); + * }, { text: 'Открыть дверь', distance: 4 }); + * opts: { text: 'Взаимодействовать', distance: 4 (метры), key: 'e' }. + */ + onInteract(fn, opts) { + if (typeof fn !== 'function') return; + _selfInteractHandlers.push(fn); + // регистрируем объект как интерактивный — main покажет подсказку + _send('self.registerInteract', { + target: _target, + text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', + distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, + key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', + holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0, + }); + }, + move(x, y, z) { + const nx = Number(x), ny = Number(y), nz = Number(z); + if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { + _send('self.move', { target: _target, x: nx, y: ny, z: nz }); + } + }, + delete() { + _send('self.delete', { target: _target }); + }, + /** + * Изменить свойства GUI-элемента (только для target.kind='gui'). + * game.self.update({ text: 'Новый текст', textColor: '#ff0000' }); + */ + update(patch) { + if (!isGui || !patch || typeof patch !== 'object') return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch }); + }, + /** Шорткат для смены текста (для Text/Button). */ + setText(text) { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { text: String(text == null ? '' : text) } }); + }, + /** Сделать элемент видимым. */ + show() { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { visible: true } }); + }, + /** Скрыть элемент. */ + hide() { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { visible: false } }); + }, + }; + return api; +} + +const game = { + player: { + /** Позиция «низа ног» игрока. */ + get position() { return { ..._playerState.position }; }, + /** + * Угол поворота игрока вокруг Y (в радианах). + * 0 = смотрит в +Z, π/2 = +X, π = -Z, -π/2 = -X. + */ + get yaw() { return _playerState.yaw || 0; }, + /** Наклон вверх/вниз (в радианах). >0 = смотрит вверх. */ + get pitch() { return _playerState.pitch || 0; }, + /** + * Нормализованный вектор взгляда (направление куда смотрит игрок). + * Удобно использовать для спавна объектов «перед собой»: + * const f = game.player.forward; + * game.scene.spawn('block:grass', { x: p.x + f.x*3, y: p.y, z: p.z + f.z*3 }); + */ + get forward() { return { ..._playerState.forward }; }, + /** + * Команда локального игрока (Фаза 4.4) — имя команды или null. + * Назначается через game.player.setTeam('Красные'). + */ + get team() { + return (_players.me && _players.me.team) || null; + }, + /** + * Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. + * Чтение возвращает текущее значение, запись — меняет в рантайме: + * game.player.crosshair = 'cross'; + */ + get crosshair() { return _playerState.crosshair || 'none'; }, + set crosshair(v) { + const allowed = ['none', 'dot', 'cross', 'circle']; + const s = String(v || 'none').toLowerCase(); + if (!allowed.includes(s)) return; + _playerState.crosshair = s; + _send('player.crosshair', { type: s }); + }, + teleport(x, y, z) { + const nx = Number(x), ny = Number(y), nz = Number(z); + if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { + _send('player.teleport', { x: nx, y: ny, z: nz }); + } + }, + /** Заблокировать/разблокировать управление игроком (WASD/прыжок). */ + setInputBlocked(blocked) { + _send('player.setInputBlocked', { blocked: !!blocked }); + }, + /** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров — + * смена полосы без отмены продвижения autorun. */ + setLaneX(x) { + const nx = Number(x); + if (Number.isFinite(nx)) _send('player.setLaneX', { x: nx }); + }, + /** Развернуть модель игрока на угол yaw (радианы). Для кат-сцен, + * где игрок стоит лицом в нужную сторону. yaw=0 — лицом в +Z. */ + setFacing(yaw) { + const y = Number(yaw); + if (Number.isFinite(y)) _send('player.setFacing', { yaw: y }); + }, + /** Проиграть эмоцию персонажа: 'wave'|'dance'|'cheer'|'sit'|'paint'. + * Работает только для R15-скинов. Разовая анимация поверх движения. */ + playEmote(name) { + if (typeof name === 'string') _send('player.emote', { name }); + }, + /** Прервать текущую эмоцию персонажа. */ + stopEmote() { + _send('player.stopEmote', {}); + }, + /** Текущее HP игрока (зеркало из main thread). */ + get hp() { return _playerState.hp ?? 100; }, + get maxHp() { return _playerState.maxHp ?? 100; }, + /** Текущее направление гравитации в Кубикон Dash (+1 вниз, -1 вверх). */ + get gravityDir() { return _playerState.gravityDir ?? 1; }, + /** Жив ли игрок (hp > 0). */ + get alive() { return (_playerState.hp ?? 100) > 0; }, + /** + * Состояние игрока: 'ground' (на земле), 'air' (в воздухе/прыжке), + * 'water' (в воде). + * if (game.player.state === 'air') { ... } + */ + get state() { return _playerState.state || 'ground'; }, + /** + * Зажата ли клавиша ПРЯМО СЕЙЧАС (для плавного движения по удержанию). + * key — 'w','a','s','d','space','shift','arrowup'... (lowercase). + * game.onTick(() => { if (game.player.isKeyDown('w')) { ... } }); + */ + isKeyDown(key) { + if (typeof key !== 'string') return false; + return !!_playerState.keys[key.toLowerCase()]; + }, + /** + * Нанести урон игроку. Учитываются i-frames (повторный вызов + * в течение ~0.5с проигнорится). + */ + damage(amount) { + const a = Number(amount); + if (!Number.isFinite(a) || a <= 0) return; + _send('player.damage', { amount: a }); + }, + /** Мгновенно убить игрока (игнорит i-frames). */ + kill() { + _send('player.damage', { amount: 99999 }); + }, + /** Восстановить здоровье. */ + heal(amount) { + const a = Number(amount); + if (!Number.isFinite(a) || a <= 0) return; + _send('player.heal', { amount: a }); + }, + /** + * Вернуть игрока на spawn-point с полным HP. + * Если spawnPoint в проекте задан — телепортирует туда. + */ + respawn() { + _send('player.respawn', null); + }, + /** + * Множитель скорости передвижения. 1 = норма, 1.5 = +50%, 0.5 = вдвое медленнее. + */ + setSpeed(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setSpeed', { mul: m }); + }, + /** + * Множитель силы прыжка. 1 = норма, 1.5 = выше, 2 = ещё выше. + */ + setJumpPower(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setJumpPower', { mul: m }); + }, + /** + * Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md. + * Надеть на игрока аксессуар (шляпа/инструмент/причёска/лицо) из + * каталога Рублокса. itemId — числовой id из rublox_items + * (только published — драфты дизайнеров не видны). + * Пример: game.player.equipAccessory(42); // надеть шляпу id=42 + */ + equipAccessory(itemId) { + const id = Number(itemId); + if (Number.isFinite(id) && id > 0) { + _send('player.equipAccessory', { itemId: id }); + } + }, + /** + * Снять аксессуар из слота: 'hat'|'tool'|'tool_left'|'hair'|'face'. + * Пример: game.player.unequipSlot('hat'); + */ + unequipSlot(slot) { + const s = String(slot || '').trim(); + if (s) _send('player.unequipSlot', { slot: s }); + }, + /** Снять все аксессуары. */ + unequipAll() { + _send('player.unequipAll', {}); + }, + /** + * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²). + * Работает в обоих направлениях gravityDir. + */ + setGravityMul(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m }); + }, + /** + * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). + * Обычный jump-импульс отключается, корабль управляется только Space. + */ + setShipMode(enabled) { + _send('player.setShipMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе + * (даже без касания земли). Обычный прыжок отключается. + */ + setUfoMode(enabled) { + _send('player.setUfoMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод Wave: движение жёстко под ±45°. + * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed. + * Гравитация и прыжок отключены. + */ + setWaveMode(enabled) { + _send('player.setWaveMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space. + * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли. + */ + setRobotMode(enabled) { + _send('player.setRobotMode', { enabled: !!enabled }); + }, + /** + * Двойной прыжок (true/false) — второй прыжок в воздухе. + */ + setDoubleJump(enabled) { + _send('player.setDoubleJump', { enabled: !!enabled }); + }, + /** + * Проиграть эмоцию-анимацию персонажа один раз. + * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть). + * game.onKey('e', () => game.player.playAnimation('wave')); + */ + playAnimation(name) { + if (typeof name !== 'string') return; + _send('player.playAnimation', { name }); + }, + /** Прервать текущую эмоцию персонажа. */ + stopAnimation() { + _send('player.stopAnimation', {}); + }, + /** + * Скользкость поверхности под игроком. 0 = нормальное движение + * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания + * клавиш). 1 = полностью скользко (инерция бесконечная). + */ + setIceFriction(value) { + const v = Number(value); + if (Number.isFinite(v)) { + _send('player.setIceFriction', { value: v }); + } + }, + /** + * Кубикон Dash: авто-бег по +X со скоростью speed (м/с). + * Передай 0 чтобы отключить. Работает только в sideview-камере — + * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview'). + * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview'); + */ + setAutoRun(speed) { + const s = Number(speed); + if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s }); + }, + /** + * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок, + * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу. + * Используется для трамплинов в Кубикон Dash. + */ + boostJump(strength) { + const s = Number(strength); + if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s }); + }, + /** + * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD). + * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает. + * Доступно только в sideview-режиме. + */ + flipGravity() { + _send('player.flipGravity', {}); + }, + /** + * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз. + * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д. + * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy. + */ + setVy(vy) { + const v = Number(vy); + if (Number.isFinite(v)) _send('player.setVy', { vy: v }); + }, + /** + * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх. + */ + setGravityDir(dir) { + const d = Number(dir); + if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d }); + }, + /** + * Показать/скрыть основной скин игрока. Используется в Кубикон Dash: + * скрываем человечка, рисуем куб-примитив через скрипт. + */ + setSkinVisible(visible) { + _send('player.setSkinVisible', { visible: !!visible }); + }, + /** + * === Задача 07: скины игрока (любая 3D-модель + магазин) === + * Сменить активный скин в Play (без перезагрузки сцены). + * game.player.setSkin('squirrel-donut'); // встроенный + * game.player.setSkin('character-a'); // человек + * Возвращает «локальный Promise» (объект с .then) — реальная смена + * асинхронна (грузится .glb). Для большинства игр можно не ждать. + */ + setSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + _currentSkin = slug; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.setSkin', { slug }); + }, + /** Дать игроку скин (разблокировать — например после покупки). */ + unlockSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.unlockSkin', { slug }); + }, + /** Список slug'ов скинов, доступных игроку (разблокированных). */ + getAvailableSkins() { + return _unlockedSkins.slice(); + }, + /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ + getAllSkins() { + return _skinsIndex.map(s => ({ ...s })); + }, + /** Текущий активный скин (slug). */ + getCurrentSkin() { + return _currentSkin; + }, + /** Подписка на смену скина: fn(slug). */ + onSkinChange(fn) { + if (typeof fn === 'function') _skinChangeHandlers.push(fn); + }, + /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ + openSkinShop() { + _send('player.openSkinShop', {}); + }, + /** Закрыть магазин скинов. */ + closeSkinShop() { + _send('player.closeSkinShop', {}); + }, + /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — + * не путать с серверной экономикой game.economy). */ + getSkinCoins() { + return _skinCoins; + }, + /** Задать баланс валюты магазина (например стартовые 200). */ + setSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, + /** Добавить валюту магазина (награда за что-то). */ + addSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, + /** + * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. + * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, + * yaw/pitch от мыши/тача игнорируются. + */ + setCameraMode(mode) { + if (typeof mode !== 'string') return; + _send('player.setCameraMode', { mode }); + }, + /** Задача 02: установить дистанцию камеры (для third-person). */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + _send('player.setCameraZoom', { distance: d }); + }, + /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; + _send('player.setCameraZoomLimits', { min: mn, max: mx }); + }, + /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ + setShiftLock(on) { + _send('player.setShiftLock', { on: !!on }); + }, + /** + * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. + * Используется чтобы пройти под низким потолком. + */ + setCrouch(enabled) { + _send('player.setCrouch', { enabled: !!enabled }); + }, + /** + * Назначить активную точку возрождения. При respawn / смерти + * игрок появится здесь. Аргумент: + * - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') — + * игрок встанет НАД этим объектом; + * - объект {x, y, z} — точные координаты. + * game.self.onInteract(() => game.player.setSpawn(game.self.ref)); + */ + setSpawn(target) { + if (typeof target === 'string' && target) { + _send('player.setSpawn', { ref: target }); + } else if (target && typeof target === 'object' + && Number.isFinite(Number(target.x))) { + _send('player.setSpawn', { + x: Number(target.x), + y: Number(target.y), + z: Number(target.z), + }); + } + }, + /** + * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2). + * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...) + * или произвольное имя предмета. + * opts: { name, equip:true (сразу взять в руки), params }. + * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры + * боя; прочее — kind='tool'. + * game.player.giveTool('blaster-blaster-a', { equip: true }); + */ + giveTool(toolType, opts) { + if (typeof toolType !== 'string' || !toolType) return; + opts = opts || {}; + const isBlaster = toolType.indexOf('blaster') === 0; + const isMelee = toolType.indexOf('weapon-') === 0; + let kind = 'tool'; + let params = {}; + if (isBlaster) { + kind = 'weapon'; + params = { + damage: 25, fireRate: 0.2, range: 60, + magazine: 12, reserve: 48, + }; + } else if (isMelee) { + kind = 'weapon'; + params = { weaponKind: 'melee', damage: 35, fireRate: 0.6, range: 3 }; + } + // opts.params переопределяет дефолты. + if (opts.params && typeof opts.params === 'object') { + params = { ...params, ...opts.params }; + } + _send('inventory.give', { + kind, + modelTypeId: toolType, + name: typeof opts.name === 'string' ? opts.name : toolType, + params, + equip: opts.equip === true, + }); + }, + /** Убрать инструмент/оружие из инвентаря по id модели или имени. */ + removeTool(toolType) { + if (typeof toolType !== 'string') return; + _send('inventory.remove', { modelTypeId: toolType, name: toolType }); + }, + /** + * Подписка: игрок применил инструмент (ЛКМ с предметом в активном + * слоте). fn получает { tool: {kind, modelTypeId, name}, point, target }. + */ + onToolUse(fn) { + if (typeof fn === 'function') _toolUseHandlers.push(fn); + }, + /** + * Назначить игроку команду (Фаза 4.4). Команда должна быть + * заранее создана через game.teams.create(). null/'' убирает. + * game.teams.create('Красные', '#ff3333'); + * game.player.setTeam('Красные'); + */ + setTeam(name) { + _send('player.setTeam', { team: typeof name === 'string' ? name : null }); + }, + }, + /** + * Таймер прохождения для лидерборда. + * game.timer.start() — запустить отсчёт (с нуля, отображается в HUD). + * game.timer.stop() — остановить (но не отправлять). + * game.timer.submit() — остановить + отправить рекорд в лидерборд. + * Сервер сохраняет если время лучше предыдущего. + */ + timer: { + start() { _send('timer.start', null); }, + stop() { _send('timer.stop', null); }, + submit() { _send('timer.submit', null); }, + }, + get self() { return _selfApi; }, + onTick(fn) { + if (typeof fn === 'function') _tickHandlers.push(fn); + }, + /** + * Выполнить fn ОДИН раз через seconds секунд. + * Возвращает id таймера — его можно отменить через game.cancel(id). + * game.after(3, () => game.ui.showText('Прошло 3 секунды!')); + */ + after(seconds, fn) { + const s = Number(seconds); + if (!Number.isFinite(s) || s < 0 || typeof fn !== 'function') return null; + const id = ++_timerSeq; + _timers.push({ id, fn, delay: s, elapsed: 0, repeat: false }); + return id; + }, + /** + * Выполнять fn КАЖДЫЕ seconds секунд (циклично). + * Возвращает id таймера — остановить через game.cancel(id). + * const t = game.every(1, () => game.log('тик')); + * game.after(10, () => game.cancel(t)); // через 10с остановить + */ + every(seconds, fn) { + const s = Number(seconds); + if (!Number.isFinite(s) || s <= 0 || typeof fn !== 'function') return null; + const id = ++_timerSeq; + _timers.push({ id, fn, delay: s, elapsed: 0, repeat: true }); + return id; + }, + /** Отменить таймер (after или every) по id, который вернул after()/every(). */ + cancel(id) { + if (id == null) return; + const i = _timers.findIndex(t => t.id === id); + if (i >= 0) _timers.splice(i, 1); + }, + /** + * Плавно изменить свойства объекта (твин — анимация перехода). + * ref — объект сцены (то что вернул scene.spawn / scene.find) или GUI-id. + * props — что менять и до какого значения: + * { x, y, z, rotationX, rotationY, rotationZ, sx, sy, sz, + * color: '#ff0000', opacity: 0..1 } + * opts — { duration: 1 (сек), easing: 'linear'|'ease'|'bounce'|'elastic'|'back', + * delay: 0, repeat: 0 (раз; -1 = бесконечно), yoyo: false, + * onDone: fn } + * Возвращает tweenId — анимацию можно прервать через game.cancelTween(id). + * + * // плавно открыть дверь за 1 секунду + * game.tween(door, { rotationY: Math.PI/2 }, { duration: 1, easing: 'ease' }); + * // пульсирующая монетка + * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); + */ + tween(ref, props, opts) { + if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; + opts = opts || {}; + const id = ++_tweenSeq; + if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; + _send('tween.start', { + tweenId: id, + ref, + props, + duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 1, + easing: typeof opts.easing === 'string' ? opts.easing : 'ease', + delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, + repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, + yoyo: !!opts.yoyo, + }); + return id; + }, + /** Прервать твин по id, который вернул game.tween(). */ + cancelTween(id) { + if (id == null) return; + delete _tweenCallbacks[id]; + _send('tween.cancel', { tweenId: id }); + }, + /** + * Подписаться на нажатие клавиши. + * key — буква 'w', 'a', 's', 'd' или специальные имена 'space', 'shift', + * 'enter', 'escape', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'. + * Сравнение case-insensitive. Если key не передан — fn вызывается на любую клавишу. + */ + onKey(key, fn) { + if (typeof key === 'function') { fn = key; key = '*'; } + if (typeof fn !== 'function') return; + const k = String(key).toLowerCase(); + (_globalKeyDownHandlers[k] = _globalKeyDownHandlers[k] || []).push(fn); + }, + /** То же что onKey, но на отпускание клавиши. */ + onKeyUp(key, fn) { + if (typeof key === 'function') { fn = key; key = '*'; } + if (typeof fn !== 'function') return; + const k = String(key).toLowerCase(); + (_globalKeyUpHandlers[k] = _globalKeyUpHandlers[k] || []).push(fn); + }, + /** + * Глобальный клик в Play-режиме. event = {point, target}. + * target = null если клик прошёл мимо объектов. + */ + onClick(fn) { + if (typeof fn === 'function') _globalClickHandlers.push(fn); + }, + /** + * Игрок коснулся любого объекта (с target-скриптом или без — + * для глобального события событие шлётся ВСЕГДА). + * event = {target}. + */ + onPlayerTouch(fn) { + if (typeof fn === 'function') _globalTouchHandlers.push(fn); + }, + /** + * Моб убит игроком (или другим способом). + * fn({mobType, position}). mobType: 'zombie' | ... + */ + onMobKilled(fn) { + if (typeof fn === 'function') _mobKilledHandlers.push(fn); + }, + /** + * Любой NPC погиб (hp дошёл до 0). fn({id, position}). + * Для конкретного NPC удобнее npc.onDeath(fn) на объекте-NPC. + */ + onNpcDeath(fn) { + if (typeof fn === 'function') _globalNpcDeathHandlers.push(fn); + }, + /** + * Игрок присоединился к комнате (Фаза 4.3). fn({sessionId, name}). + */ + onPlayerJoin(fn) { + if (typeof fn === 'function') _playerJoinHandlers.push(fn); + }, + /** + * Катсцена камеры доиграла (Фаза 5.7). fn() — без аргументов. + * game.camera.cutscene([...]); + * game.onCutsceneDone(() => game.camera.reset()); + */ + onCutsceneDone(fn) { + if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); + }, + onVehicleEnter(fn) { if (typeof fn === 'function') _vehicleEnterHandlers.push(fn); }, + onVehicleExit(fn) { if (typeof fn === 'function') _vehicleExitHandlers.push(fn); }, + /** Игрок покинул комнату. fn({sessionId, name}). */ + onPlayerLeave(fn) { + if (typeof fn === 'function') _playerLeaveHandlers.push(fn); + }, + /** + * Подписаться на адресное сообщение (Фаза 4.3). fn({from, data}). + * game.onMessage('подарок', (msg) => game.ui.showText('от ' + msg.from)); + */ + onMessage(name, fn) { + if (typeof name !== 'string' || typeof fn !== 'function') return; + (_mpMessageHandlers[name] = _mpMessageHandlers[name] || []).push(fn); + }, + /** + * Отправить сообщение игроку (Фаза 4.3). + * game.sendTo(player, 'подарок', { gold: 100 }); + * player — объект из game.players.* или его sessionId. + */ + sendTo(player, name, data) { + if (typeof name !== 'string') return; + const sessionId = typeof player === 'string' + ? player + : (player && player.sessionId); + if (!sessionId) return; + _send('mp.sendTo', { sessionId, name, data }); + }, + /** + * Подписаться на изменение HP игрока (получение урона / лечение / смерть). + * fn(event) где event = { hp, maxHp, source, damaged, delta }. + * - source — строка ('script', 'zombie', 'fall', 'lava', ...) или null. + * - delta — изменение HP (отрицательное = урон, положительное = лечение). + * - damaged — true если это был урон. + */ + onHpChange(fn) { + if (typeof fn === 'function') _hpChangeHandlers.push(fn); + }, + /** + * Игрок погиб (hp дошло до 0). Срабатывает один раз на смерть. + * game.onPlayerDied(() => game.ui.showText('Игра окончена', 3)); + */ + onPlayerDied(fn) { + if (typeof fn === 'function') _playerDiedHandlers.push(fn); + }, + /** Игрок прыгнул. */ + onPlayerJump(fn) { + if (typeof fn === 'function') _playerJumpHandlers.push(fn); + }, + /** Игрок приземлился (коснулся земли после полёта/прыжка). */ + onPlayerLand(fn) { + if (typeof fn === 'function') _playerLandHandlers.push(fn); + }, + /** + * UI / HUD — текст и счётчики поверх viewport в Play. + * game.ui.showText('Привет', 2) — флешит текст в центре + * game.ui.score = 100 — счётчик в углу + * game.ui.timer = 60 — таймер + * game.ui.set('hp', 'HP: 100', {color}) — произвольная именованная метка + * game.ui.remove('hp') + * game.ui.clear() — убрать всё + */ + ui: (() => { + const _state = { score: null, timer: null }; + return { + get score() { return _state.score; }, + set score(v) { _state.score = v; _send('ui.set', { id: '__score', text: v == null ? null : 'Очки: ' + v }); }, + get timer() { return _state.timer; }, + set timer(v) { + _state.timer = v; + if (v == null) { _send('ui.set', { id: '__timer', text: null }); return; } + const n = Number(v); + if (!Number.isFinite(n)) return; + const mm = Math.floor(Math.max(0, n) / 60); + const ss = Math.floor(Math.max(0, n) % 60); + const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; + _send('ui.set', { id: '__timer', text: txt }); + }, + /** Кратковременный текст по центру экрана. seconds=2 по умолчанию. */ + showText(text, seconds) { + _send('ui.flash', { + text: String(text == null ? '' : text), + seconds: Number.isFinite(Number(seconds)) ? Number(seconds) : 2, + }); + }, + /** + * Установить произвольную метку. + * id — уникальное имя для последующих обновлений и remove. + * opts: { x, y } — позиция в процентах (0..100), { color, size } — стилизация. + */ + set(id, text, opts) { + if (typeof id !== 'string' || !id) return; + _send('ui.set', { + id, + text: text == null ? null : String(text), + opts: opts || null, + }); + }, + /** Убрать метку по id. */ + remove(id) { + if (typeof id !== 'string' || !id) return; + _send('ui.set', { id, text: null }); + }, + /** Убрать весь HUD. */ + clear() { + _state.score = null; + _state.timer = null; + _send('ui.clear', null); + }, + }; + })(), + /** API сцены: spawn/delete/find/all. */ + scene: { + /** + * Создать объект на сцене. + * type: 'block:' / 'primitive:' / 'model:' / 'light:point'. + * opts: { x, y, z, sx, sy, sz, color, material, rotationY, name, lifetime, + * brightness, range }. + * lifetime — если задан (секунды), объект сам удалится через это время. + * brightness/range — только для 'light:point' (яркость и радиус лампы). + * Возвращает строку-ref (можно использовать в delete/getPosition). + */ + spawn(type, opts) { + if (typeof type !== 'string') return null; + opts = opts || {}; + // Алиас: 'light:point' — это примитив-лампа. + if (type === 'light:point' || type === 'light') type = 'primitive:light'; + const x = Number(opts.x) || 0; + const y = Number(opts.y) || 0; + const z = Number(opts.z) || 0; + const colon = type.indexOf(':'); + if (colon < 0) return null; + const kind = type.slice(0, colon); + const subType = type.slice(colon + 1); + if (kind === 'block') { + const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); + const ref = 'block:' + ix + ',' + iy + ',' + iz; + // color — для окрашиваемых блоков (studs-block, задача 09). + _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + if (kind === 'primitive' || kind === 'model') { + _localRefSeq++; + const ref = kind + ':_local_' + _localRefSeq; + _send('scene.spawn', { + kind, subType, x, y, z, + sx: opts.sx, sy: opts.sy, sz: opts.sz, + color: opts.color, material: opts.material, + rotationY: opts.rotationY, + name: opts.name, + brightness: opts.brightness, range: opts.range, + effect: opts.effect, + // anchored:false → объект падает (физика). По умолчанию + // примитив заякорен (anchored:true) и висит на месте. + anchored: opts.anchored, + // canCollide — можно сделать объект проходимым (зона). + canCollide: opts.canCollide, + // visible:false → объект скрыт (показать через setVisible). + visible: opts.visible, + // textureAsset — id картинки из ассетов проекта на грани. + textureAsset: opts.textureAsset, + ref, + }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + // Пользовательская модель из воксельного редактора моделей. + // type = 'user:', где — числовой id модели в проекте. + // ref пользовательских инстансов в сцене — 'usermodel:'. + if (kind === 'user') { + _localRefSeq++; + const ref = 'usermodel:_local_' + _localRefSeq; + _send('scene.spawn', { + kind: 'userModel', + // subType — это полная строка 'user:' (как принимает + // UserModelManager.addInstance). Восстанавливаем её. + subType: 'user:' + subType, + x, y, z, + rotationY: opts.rotationY, + scale: opts.scale, + name: opts.name, + ref, + }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + if (kind === 'vehicle') { + _localRefSeq++; + const ref = 'vehicle:_local_' + _localRefSeq; + _send('scene.spawn', { + kind: 'vehicle', subType, + model: opts.model || 'car-sedan', color: opts.color, name: opts.name, + params: opts.params || {}, + x, y, z, rotationY: opts.rotationY || 0, ref, + }); + return _getOrCreateInstance(ref, 'vehicle') || ref; + } + return null; + }, + /** Удалить объект по ref. */ + delete(ref) { + if (typeof ref !== 'string' || !ref) return; + _send('scene.delete', { ref }); + }, + /** + * Удалить объект через seconds секунд (авто-удаление). + * const p = game.scene.spawn('primitive:cube', { x, y, z }); + * game.scene.deleteAfter(p, 5); // исчезнет через 5 секунд + */ + deleteAfter(ref, seconds) { + _scheduleDelete(ref, seconds); + }, + /** + * Переместить объект (для моделей/примитивов) — без target-скрипта. + * ref — то что вернул spawn() или scene.find(). + */ + move(ref, x, y, z) { + if (typeof ref !== 'string') return; + const nx = Number(x), ny = Number(y), nz = Number(z); + if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) return; + // Парсим ref: 'primitive:_local_3' или 'primitive:realId' или 'model:id' + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive' && kind !== 'model') return; + _send('self.move', { + target: { kind, id, ref: id }, + x: nx, y: ny, z: nz, + }); + }, + /** + * Повернуть объект вокруг Y (в радианах). Только для примитивов. + */ + rotate(ref, ry) { + if (typeof ref !== 'string') return; + const r = Number(ry); + if (!Number.isFinite(r)) return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.rotate', { kind, id, rotationY: r }); + }, + /** + * Установить полный поворот (rx, ry, rz) в радианах. Для примитивов. + * Нужно для Кубикон Dash: куб крутится вокруг Z в воздухе. + */ + setRotation(ref, rx, ry, rz) { + if (typeof ref !== 'string') return; + const x = Number(rx), y = Number(ry), z = Number(rz); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setRotation', { id, rx: x, ry: y, rz: z }); + }, + /** + * Изменить collision примитива (true = твёрдый, false = проваливается). + */ + setCollide(ref, can) { + if (typeof ref !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setCollide', { id, canCollide: !!can }); + }, + /** + * Изменить видимость примитива/модели (true = видно, false = скрыт). + */ + setVisible(ref, vis) { + if (typeof ref !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive' && kind !== 'model') return; + _send('scene.setVisible', { kind, id, visible: !!vis }); + }, + /** + * Изменить цвет примитива (hex-строка типа '#ff0000'). + */ + setColor(ref, color) { + if (typeof ref !== 'string' || typeof color !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setColor', { id, color }); + }, + /** + * Повесить текст-метку НАД объектом (имя/HP над персонажем, врагом). + * Метка всегда повёрнута к камере и видна поверх геометрии. + * game.scene.setLabel(enemy, 'Босс HP: 100', { color: '#ff4444' }); + * opts: { color: '#fff', height: 2.5 (м над объектом), size: 1 }. + * Работает для примитивов и моделей. + */ + setLabel(ref, text, opts) { + if (typeof ref !== 'string') return; + _send('scene.setLabel', { + ref, + text: String(text == null ? '' : text), + opts: opts || {}, + }); + }, + /** Убрать метку с объекта. */ + clearLabel(ref) { + if (typeof ref !== 'string') return; + _send('scene.clearLabel', { ref }); + }, + /** + * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. + * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). + */ + setOpacity(ref, value) { + if (typeof ref !== 'string') return; + const v = Number(value); + if (!Number.isFinite(v)) return; + if (ref.indexOf('primitive:') !== 0) return; + // шлём ПОЛНЫЙ ref — GameRuntime._resolvePrimitiveId резолвит + // локальный ref ('primitive:_local_N') через _localToReal. + _send('scene.setOpacity', { ref, opacity: Math.max(0, Math.min(1, v)) }); + }, + /** + * Масштаб примитива по осям. 1 = обычный размер, 2 = вдвое больше. + * Можно передать одно число (одинаково по всем осям) или три. + * game.scene.setScale(box, 2); // куб ×2 + * game.scene.setScale(box, 1, 3, 1); // вытянуть по Y + */ + setScale(ref, sx, sy, sz) { + if (typeof ref !== 'string') return; + let nx = Number(sx); + if (!Number.isFinite(nx) || nx <= 0) return; + let ny = Number(sy), nz = Number(sz); + // один аргумент → одинаково по всем осям + if (!Number.isFinite(ny)) ny = nx; + if (!Number.isFinite(nz)) nz = nx; + if (ref.indexOf('primitive:') !== 0) return; + _send('scene.setScale', { ref, sx: nx, sy: ny, sz: nz }); + }, + /** + * Материал примитива: 'default' | 'metal' | 'glass' | 'neon'. + * game.scene.setMaterial(box, 'neon'); // куб светится + */ + setMaterial(ref, name) { + if (typeof ref !== 'string' || typeof name !== 'string') return; + if (ref.indexOf('primitive:') !== 0) return; + _send('scene.setMaterial', { ref, material: name }); + }, + /** + * Создать копию примитива со смещением. Возвращает ref новой копии. + * const copy = game.scene.clone(box, { dx: 3 }); // копия на 3 правее + * offset: { dx, dy, dz } — смещение относительно оригинала. + */ + clone(ref, offset) { + if (typeof ref !== 'string') return null; + if (ref.indexOf('primitive:') !== 0) return null; + offset = offset || {}; + _localRefSeq++; + const newRef = 'primitive:_local_' + _localRefSeq; + _send('scene.clone', { + ref, + newRef, + dx: Number(offset.dx) || 0, + dy: Number(offset.dy) || 0, + dz: Number(offset.dz) || 0, + }); + return newRef; + }, + /** + * Установить динамическую текстуру примитива из dataURL. + * dataUrl — base64 PNG (например, из canvas.toDataURL()). + * Используется для GD-скинов: canvas-фабрика рисует лицо куба → шлёт сюда. + */ + setTexture(ref, dataUrl) { + if (typeof ref !== 'string' || typeof dataUrl !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setTexture', { id, dataUrl }); + }, + /** + * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ). + * Все примитивы внутри папки повернутся как единое целое. + * game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 }); + */ + setFolderYaw(folderName, angle, pivot) { + if (typeof folderName !== 'string') return; + const a = Number(angle); + if (!Number.isFinite(a)) return; + if (!pivot || !Number.isFinite(Number(pivot.x)) + || !Number.isFinite(Number(pivot.z))) return; + _send('scene.setFolderYaw', { + folderName, + angle: a, + pivot: { x: Number(pivot.x), z: Number(pivot.z) }, + }); + }, + /** + * Найти объекты по name. Возвращает массив Instance-прокси (паритет + * со студией). Instance coerces в строку-ref, поэтому код, принимавший + * строковый ref, продолжает работать. + */ + find(name) { + const out = []; + const n = String(name || '').toLowerCase(); + for (const m of _sceneIndex.models) { + if (m.name && String(m.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(m.ref, 'model')); + } + } + for (const p of _sceneIndex.primitives) { + if (p.name && String(p.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(p.ref, 'primitive')); + } + } + return out; + }, + /** Первый объект с таким name или null. */ + findOne(name) { + const arr = this.find(name); + return arr.length > 0 ? arr[0] : null; + }, + /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ + all(kind) { + if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); + if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); + if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); + return []; + }, + /** + * Сохранить произвольное значение НА объекте (атрибут). + * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает. + * game.scene.setData(door, 'locked', true); + * game.scene.setData(chest, 'gold', 100); + */ + setData(ref, key, value) { + if (typeof ref !== 'string' || typeof key !== 'string') return; + // оптимистично обновляем локальное зеркало (до прихода снапшота) + if (!_dataIndex[ref]) _dataIndex[ref] = {}; + _dataIndex[ref][key] = value; + _send('scene.setData', { ref, key, value }); + }, + /** + * Прочитать атрибут объекта. Возвращает значение или undefined. + * if (game.scene.getData(door, 'locked')) { ... } + */ + getData(ref, key) { + if (typeof ref !== 'string' || typeof key !== 'string') return undefined; + const bag = _dataIndex[ref]; + return bag ? bag[key] : undefined; + }, + /** + * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. + * Помечаешь объекты тегом, потом находишь все объекты с тегом. + * game.scene.tag(enemy, 'враг'); + * for (const e of game.scene.getTagged('враг')) { ... } + */ + tag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + // Оптимистично обновляем локальное зеркало (до прихода снапшота). + if (!_dataIndex[ref]) _dataIndex[ref] = {}; + const cur = Array.isArray(_dataIndex[ref].__tags) ? _dataIndex[ref].__tags : []; + if (!cur.includes(tag)) _dataIndex[ref].__tags = [...cur, tag]; + _send('scene.tag', { ref, tag }); + }, + /** Снять тег с объекта. */ + untag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + if (_dataIndex[ref] && Array.isArray(_dataIndex[ref].__tags)) { + _dataIndex[ref].__tags = _dataIndex[ref].__tags.filter(t => t !== tag); + } + _send('scene.untag', { ref, tag }); + }, + /** True если у объекта есть такой тег. */ + hasTag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string') return false; + const bag = _dataIndex[ref]; + return !!(bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)); + }, + /** Список ref всех объектов с заданным тегом. */ + getTagged(tag) { + if (typeof tag !== 'string' || !tag) return []; + const out = []; + for (const ref of Object.keys(_dataIndex)) { + const bag = _dataIndex[ref]; + if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)) { + out.push(ref); + } + } + return out; + }, + /** Позиция объекта по ref или null. Работает и с локальным ref + * от scene.spawn (резолвит в реальный через _spawnLocalToReal). */ + getPosition(ref) { + if (typeof ref !== 'string') return null; + // Локальный ref scene.spawn → реальный. + const r = _spawnLocalToReal[ref] || ref; + for (const b of _sceneIndex.blocks) if (b.ref === r) return { x: b.x, y: b.y, z: b.z }; + for (const m of _sceneIndex.models) if (m.ref === r) return { x: m.x, y: m.y, z: m.z }; + for (const p of _sceneIndex.primitives) if (p.ref === r) return { x: p.x, y: p.y, z: p.z }; + return null; + }, + /** + * Создать NPC — управляемого скриптом персонажа (Фаза 4.1). + * modelType — id модели (как в game.scene.spawn('model:...')). + * opts: { x, y, z, rotationY, hp, name, speed }. + * Возвращает объект-NPC с методами: + * npc.moveTo(x, z) — идти в точку + * npc.follow(ref) — следовать за объектом ('player' или ref) + * npc.stop() — остановиться + * npc.say(text, sec) — реплика над головой + * npc.damage(amount) — нанести урон + * npc.remove() — убрать со сцены + * npc.onDeath(fn) — колбэк при гибели NPC + * npc.position — {x,y,z} (актуальная позиция) + * npc.hp / npc.name — текущие значения + * npc.ref — строковый ref NPC + * + * const trader = game.scene.spawnNpc('robot', { x: 5, z: 0, name: 'Боб' }); + * trader.say('Привет!'); + * trader.follow('player'); + */ + spawnNpc(modelType, opts) { + if (typeof modelType !== 'string') return null; + opts = opts || {}; + _npcRefSeq++; + const ref = 'npc:_local_' + _npcRefSeq; + _send('npc.spawn', { + modelType, ref, + x: Number(opts.x) || 0, + y: Number(opts.y) || 0, + z: Number(opts.z) || 0, + rotationY: Number(opts.rotationY) || 0, + hp: Number.isFinite(Number(opts.hp)) ? Number(opts.hp) : undefined, + name: typeof opts.name === 'string' ? opts.name : undefined, + speed: Number.isFinite(Number(opts.speed)) ? Number(opts.speed) : undefined, + }); + return _makeNpcProxy(ref); + }, + /** Список всех NPC на сцене — массив объектов {id, name, x,y,z, hp, ...}. */ + npcs() { + return _npcs.map(n => ({ ...n })); + }, + /** + * Эффект частиц в точке. Авто-удаляется через duration секунд. + * type: 'fire' | 'smoke' | 'sparks' | 'magic' | 'explosion' | 'confetti' + * position: {x,y,z} + * options: { duration: 2 (сек), count: 50 (множитель), color: '#ffaa00' } + */ + spawnParticles(type, position, options) { + if (typeof type !== 'string' || !position) return; + _send('scene.particles', { + type, + position: { + x: Number(position.x) || 0, + y: Number(position.y) || 0, + z: Number(position.z) || 0, + }, + duration: options && Number.isFinite(Number(options.duration)) ? Number(options.duration) : 1.5, + count: options && Number.isFinite(Number(options.count)) ? Number(options.count) : 1, + color: options?.color || null, + }); + }, + /** + * Снимок всех живых мобов (зомби и т.д.). Обновляется каждый tick. + * Возвращает массив { id, mobType, x, y, z, hp }. + * Опциональный фильтр: { mobType?: 'zombie', within?: { x, y?, z, radius } } + */ + mobs(filter) { + const arr = _mobs.slice(); + if (!filter) return arr; + const wantType = typeof filter.mobType === 'string' ? filter.mobType : null; + const within = filter.within; + if (wantType == null && !within) return arr; + const out = []; + const wx = within ? Number(within.x) || 0 : 0; + const wz = within ? Number(within.z) || 0 : 0; + const wr2 = within ? (Number(within.radius) || 0) ** 2 : 0; + for (const m of arr) { + if (wantType && m.mobType !== wantType) continue; + if (within) { + const dx = m.x - wx, dz = m.z - wz; + if (dx*dx + dz*dz > wr2) continue; + } + out.push(m); + } + return out; + }, + /** + * Убить моба (или массив мобов). Принимает объект из mobs() или его id. + * Запускает обычную смерть (с эффектами + onMobKilled). + */ + killMob(target) { + if (target == null) return; + const items = Array.isArray(target) ? target : [target]; + for (const it of items) { + let id = null; + if (typeof it === 'number') id = it; + else if (it && typeof it === 'object' && 'id' in it) id = Number(it.id); + if (Number.isFinite(id)) _send('mob.kill', { id }); + } + }, + /** + * Высота поверхности гладкого ландшафта в точке (x, z). + * Билинейная интерполяция по карте высот (raycast по реальному + * мешу, снятой при старте). Нужно чтобы скрипты ставили объекты + * (животных и т.п.) ТОЧНО на землю, а не парили/тонули. + * + * Возвращает Y поверхности, или null если карта высот не пришла + * (нет гладкого ландшафта в проекте). + * + * const y = game.scene.surfaceY(p.x, p.z); + * if (y !== null) game.self.move(nx, y, nz); + */ + surfaceY(x, z) { + const hm = _terrainHM; + if (!hm || !hm.heights) return null; + const nx = Number(x), nz = Number(z); + if (!Number.isFinite(nx) || !Number.isFinite(nz)) return null; + const fx = (nx - hm.origin.x) / hm.step; + const fz = (nz - hm.origin.z) / hm.step; + let c0 = Math.floor(fx), r0 = Math.floor(fz); + // clamp в пределы карты + if (c0 < 0) c0 = 0; if (c0 > hm.cols - 2) c0 = hm.cols - 2; + if (r0 < 0) r0 = 0; if (r0 > hm.rows - 2) r0 = hm.rows - 2; + const tx = Math.max(0, Math.min(1, fx - c0)); + const tz = Math.max(0, Math.min(1, fz - r0)); + const H = hm.heights; + const W = hm.cols; + const h00 = H[r0 * W + c0]; + const h10 = H[r0 * W + c0 + 1]; + const h01 = H[(r0 + 1) * W + c0]; + const h11 = H[(r0 + 1) * W + c0 + 1]; + // null-ячейки заменяем на среднее валидных + const vals = []; + if (h00 != null) vals.push(h00); + if (h10 != null) vals.push(h10); + if (h01 != null) vals.push(h01); + if (h11 != null) vals.push(h11); + if (vals.length === 0) return null; + const avg = vals.reduce((a, b) => a + b, 0) / vals.length; + const v00 = h00 != null ? h00 : avg; + const v10 = h10 != null ? h10 : avg; + const v01 = h01 != null ? h01 : avg; + const v11 = h11 != null ? h11 : avg; + const a = v00 * (1 - tx) + v10 * tx; + const b = v01 * (1 - tx) + v11 * tx; + return a * (1 - tz) + b * tz; + }, + }, + + /** + * Физика — луч (raycast), импульсы, взрывы. + */ + physics: { + /** + * Пустить луч из точки origin в направлении dir. + * Возвращает { hit, ref, point, distance } — hit=true если во что-то + * попали. ref — объект (primitive) в который попали. + * Синхронный — можно звать прямо в onClick для стрельбы. + * const r = game.physics.raycast(game.player.position, game.player.forward); + * if (r.hit) game.scene.delete(r.ref); + * opts: { maxDistance: 100, ignore: [ref, ...] } + */ + raycast(origin, dir, opts) { + opts = opts || {}; + const ox = Number(origin?.x), oy = Number(origin?.y), oz = Number(origin?.z); + let dx = Number(dir?.x), dy = Number(dir?.y), dz = Number(dir?.z); + if (![ox, oy, oz, dx, dy, dz].every(Number.isFinite)) { + return { hit: false, ref: null, point: null, distance: Infinity }; + } + // нормализуем направление + const dlen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; + dx /= dlen; dy /= dlen; dz /= dlen; + const maxDist = Number.isFinite(Number(opts.maxDistance)) ? Number(opts.maxDistance) : 100; + const ignore = Array.isArray(opts.ignore) ? opts.ignore : []; + let best = { hit: false, ref: null, point: null, distance: Infinity }; + // перебираем примитивы — ray vs AABB (с учётом поворота вокруг Y) + for (const p of _sceneIndex.primitives) { + if (p.visible === false) continue; + if (ignore.includes(p.ref)) continue; + const hw = (p.sx || 1) / 2, hh = (p.sy || 1) / 2, hd = (p.sz || 1) / 2; + // переводим луч в локальные координаты примитива (обратный поворот по Y) + const ang = -(p.rotationY || 0); + const cos = Math.cos(ang), sin = Math.sin(ang); + const rx = ox - p.x, rz = oz - p.z; + const lox = rx * cos - rz * sin; + const loz = rx * sin + rz * cos; + const loy = oy - p.y; + const ldx = dx * cos - dz * sin; + const ldz = dx * sin + dz * cos; + const ldy = dy; + // slab-тест ray vs AABB + const t = _rayAabb(lox, loy, loz, ldx, ldy, ldz, hw, hh, hd); + if (t != null && t >= 0 && t <= maxDist && t < best.distance) { + best = { + hit: true, ref: p.ref, distance: t, + point: { x: ox + dx*t, y: oy + dy*t, z: oz + dz*t }, + }; + } + } + return best; + }, + /** + * Задать скорость объекту (м/с). Объект полетит с этой скоростью. + * game.physics.setVelocity(ball, { x: 0, y: 10, z: 5 }); + */ + setVelocity(ref, vel) { + if (typeof ref !== 'string' || !vel) return; + _send('physics.setVelocity', { + ref, + vx: Number(vel.x) || 0, vy: Number(vel.y) || 0, vz: Number(vel.z) || 0, + }); + }, + /** + * Толкнуть объект импульсом (резкий толчок). + * game.physics.applyImpulse(box, { x: 15, y: 5, z: 0 }); + */ + applyImpulse(ref, impulse) { + if (typeof ref !== 'string' || !impulse) return; + _send('physics.applyImpulse', { + ref, + ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0, + }); + }, + /** + * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе. + * game.physics.explode({ x, y, z }, 5, { damage: 40 }); + * opts: { damage: 30, force: 0 } — урон и сила отброса. + */ + explode(pos, radius, opts) { + if (!pos) return; + opts = opts || {}; + _send('physics.explode', { + x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, + radius: Number(radius) || 3, + damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30, + force: Number(opts.force) || 0, + }); + }, + /** + * Проходимость объекта или группы (Фаза 5.9, collision groups). + * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам + * с этим тегом — теги работают как collision groups). + * on=true — игрок проходит сквозь (объект остаётся видимым), + * on=false — снова твёрдый. + * game.physics.passThrough(wall, true); // одна стена + * game.physics.passThrough('призраки', true); // вся группа по тегу + */ + passThrough(target, on) { + if (typeof target !== 'string' || !target) return; + _send('physics.passThrough', { target, on: !!on }); + }, + }, + + /** + * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов. + */ + gui: { + /** Найти ID элемента по имени. Возвращает строку или null. */ + find(name) { + if (typeof name !== 'string') return null; + const n = name.toLowerCase(); + for (const g of _guiIndex) { + if (g.name && String(g.name).toLowerCase() === n) return g.id; + } + return null; + }, + /** Список ID всех элементов. */ + all() { + return _guiIndex.map(g => g.id); + }, + /** Получить копию свойств элемента. */ + get(id) { + if (typeof id !== 'string') return null; + const found = _guiIndex.find(g => g.id === id); + return found ? { ...found } : null; + }, + /** + * Изменить свойства элемента. + * game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' }); + */ + update(id, patch) { + if (typeof id !== 'string' || !patch || typeof patch !== 'object') return; + _send('gui.update', { id, patch }); + }, + /** Сделать элемент видимым. */ + show(id) { + if (typeof id !== 'string') return; + _send('gui.update', { id, patch: { visible: true } }); + }, + /** Скрыть элемент (но не удалять). */ + hide(id) { + if (typeof id !== 'string') return; + _send('gui.update', { id, patch: { visible: false } }); + }, + /** + * Создать новый элемент. Возвращает локальный ref-id (строку). + * const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' }); + */ + create(type, opts) { + if (typeof type !== 'string') return null; + _localRefSeq++; + const localRef = '_gui_local_' + _localRefSeq; + _send('gui.create', { type, opts: opts || {}, localRef }); + return localRef; + }, + /** Удалить элемент по id. */ + remove(id) { + if (typeof id !== 'string') return; + _send('gui.remove', { id }); + }, + /** + * Подписаться на клик по кнопке (по id). + * game.gui.onClick('gui_xxx', () => { game.log('clicked!'); }); + */ + onClick(id, fn) { + if (typeof id !== 'string' || typeof fn !== 'function') return; + (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn); + }, + /** + * Подписаться на ввод в поле TextBox — срабатывает когда игрок + * нажал Enter. fn получает введённый текст. + * game.gui.onSubmit('gui_name', (text) => { + * game.ui.showText('Привет, ' + text); + * }); + */ + onSubmit(id, fn) { + if (typeof id !== 'string' || typeof fn !== 'function') return; + (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); + }, + /** Задача 03: tween свойства GUI-элемента. + * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, + * bgColor, textColor, borderColor } (любое числовое или hex-цвет). + * opts: { duration, easing, delay, repeat, reverses, onDone } */ + tween(id, props, opts) { + if (typeof id !== 'string' || !id) return null; + if (!props || typeof props !== 'object') return null; + opts = opts || {}; + const tid = ++_tweenSeq; + if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; + _send('gui.tween', { + tweenId: tid, id, props, + duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, + easing: typeof opts.easing === 'string' ? opts.easing : 'ease', + delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, + repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, + reverses: !!opts.reverses, + }); + return tid; + }, + /** Отменить tween по id (возвращённому из game.gui.tween). */ + cancelTween(tweenId) { + if (!Number.isFinite(tweenId)) return; + _send('gui.cancelTween', { tweenId }); + delete _tweenCallbacks[tweenId]; + }, + }, + /** + * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). + */ + camera: { + /** + * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно), + * dur в секундах. Затухает к 0. + */ + shake(amp, dur) { + const a = Number(amp), d = Number(dur); + if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return; + _send('camera.shake', { amp: a, dur: d }); + }, + /** + * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий, + * 40 — «зум». Диапазон 10..130. + * game.camera.setFov(90); + */ + setFov(degrees) { + const d = Number(degrees); + if (Number.isFinite(d)) _send('camera.fov', { degrees: d }); + }, + /** + * Привязать камеру к объекту — она следит за ним. + * ref — объект сцены. opts: { distance, height } — отступ камеры. + * game.camera.focusOn(bossRef, { distance: 12, height: 6 }); + */ + focusOn(ref, opts) { + if (typeof ref !== 'string') return; + opts = opts || {}; + _send('camera.focus', { + ref, + distance: Number.isFinite(Number(opts.distance)) ? Number(opts.distance) : undefined, + height: Number.isFinite(Number(opts.height)) ? Number(opts.height) : undefined, + }); + }, + /** + * Катсцена — плавный пролёт камеры по точкам. + * points — массив позиций камеры [{x,y,z}, ...]. + * opts: { lookAt: [{x,y,z}, ...] — точки взгляда (по одной на + * позицию), segDuration: секунд на отрезок }. + * game.camera.cutscene( + * [{x:0,y:10,z:-20}, {x:0,y:5,z:0}], + * { lookAt: [{x:0,y:0,z:0}, {x:0,y:0,z:0}], segDuration: 3 } + * ); + */ + cutscene(points, opts) { + if (!Array.isArray(points) || points.length < 2) return; + opts = opts || {}; + _send('camera.cutscene', { + points, + lookAt: Array.isArray(opts.lookAt) ? opts.lookAt : [], + segDuration: Number.isFinite(Number(opts.segDuration)) + ? Number(opts.segDuration) : 2, + }); + }, + /** Вернуть камеру под управление игрока. */ + reset() { + _send('camera.reset', {}); + }, + }, + /** + * Главное меню игры (задача 13) — оркестратор поверх camera.cutscene + gui + * + audio + loading. Живая 3D-сцена + cinematic-камера + GUI + музыка; + * клик ИГРАТЬ → переход в игру. + */ + mainMenu: { + _onShow: [], _onPlay: [], _onHide: [], + _active: false, + _curMusic: null, + _opts: null, + _camCfg: null, + _loopArmed: false, + show(opts) { + opts = opts && typeof opts === 'object' ? opts : {}; + this._opts = opts; + this._active = true; + _send('player.setInputBlocked', { blocked: true }); + game.hud.setVisible(false); + this._camCfg = opts.camera || { mode: 'orbit', center: { x: 0, y: 1, z: 0 }, radius: 6, height: 2, duration: 12 }; + this.setCamera(this._camCfg); + if (!this._loopArmed) { + this._loopArmed = true; + const self = this; + game.onCutsceneDone(() => { + if (self._active && self._camCfg) self.setCamera(self._camCfg); + }); + } + this._buildGui(opts); + if (opts.music && typeof opts.music === 'string') { + this._curMusic = opts.music; + _send('audio.playMusic', { trackId: opts.music }); + } + for (const fn of this._onShow) _safeCall(fn, undefined, 'mainMenu.onShow'); + }, + setCamera(cam) { + cam = cam && typeof cam === 'object' ? cam : {}; + if (this._active) this._camCfg = cam; + const mode = cam.mode || 'orbit'; + const dur = Number.isFinite(Number(cam.duration)) ? Number(cam.duration) : 12; + if (mode === 'static') { + const p = cam.position || { x: 0, y: 5, z: 8 }; + const t = cam.target || { x: 0, y: 1, z: 0 }; + _send('camera.cutscene', { points: [p, p], lookAt: [t, t], segDuration: 9999 }); + return; + } + if (mode === 'orbit') { + const c = cam.center || { x: 0, y: 1, z: 0 }; + const r = Number.isFinite(Number(cam.radius)) ? Number(cam.radius) : 6; + const h = Number.isFinite(Number(cam.height)) ? Number(cam.height) : 2; + const N = 16; + const pts = [], looks = []; + for (let i = 0; i <= N; i++) { + const a = (i / N) * Math.PI * 2; + pts.push({ x: c.x + Math.cos(a) * r, y: c.y + h, z: c.z + Math.sin(a) * r }); + looks.push({ x: c.x, y: c.y, z: c.z }); + } + _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / N }); + return; + } + if (mode === 'preset-cuts') { + const cuts = Array.isArray(cam.cuts) ? cam.cuts : []; + if (cuts.length < 1) return; + const pts = [], looks = []; + for (const c of cuts) { + const p = c.position || { x: 0, y: 3, z: 6 }; + const t = c.target || { x: 0, y: 1, z: 0 }; + pts.push(p, p); looks.push(t, t); + } + _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: (cuts[0].duration || 2) }); + return; + } + const wps = Array.isArray(cam.waypoints) ? cam.waypoints : []; + if (wps.length < 2) return; + const pts = wps.map(w => w.position || { x: 0, y: 2, z: 0 }); + const looks = wps.map(w => w.target || { x: 0, y: 1, z: 0 }); + _send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / Math.max(1, pts.length - 1) }); + }, + setPatchNotes(pn) { + pn = pn && typeof pn === 'object' ? pn : {}; + const title = (pn.title || '') + (pn.version ? ' (' + pn.version + '):' : ':'); + const items = Array.isArray(pn.items) ? pn.items : []; + game.gui.create('text', { + id: '_mm_pn_title', x: 78, y: 28, w: 40, h: 6, anchor: 'center', + text: title, textColor: '#ffffff', textSize: 26, fontWeight: 900, + textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, + }); + items.slice(0, 7).forEach((it, i) => { + game.gui.create('text', { + id: '_mm_pn_' + i, x: 78, y: 36 + i * 6, w: 40, h: 5, anchor: 'center', + text: '- ' + it, textColor: '#e8edf5', textSize: 20, fontWeight: 700, + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, + }); + }); + }, + _buildGui(opts) { + if (opts.title) { + game.gui.create('text', { + id: '_mm_logo', x: 30, y: 12, w: 56, h: 12, anchor: 'center', + text: String(opts.title), textColor: '#ffd23a', textSize: 48, fontWeight: 900, + textStroke: { color: '#000', width: 4 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow', + }); + } + if (opts.patchNotes) this.setPatchNotes(opts.patchNotes); + game.gui.create('button', { + id: '_mm_play', x: 84, y: 90, w: 28, h: 11, anchor: 'center', + text: opts.playButtonText || 'ИГРАТЬ', + bgGradient: { stops: ['#ffe066', '#e0a000'], angle: 90 }, + textColor: '#3a2a00', textSize: 30, fontWeight: 900, borderRadius: 14, + textStroke: { color: '#fff7d0', width: 1 }, + hover: { scale: 1.06, brightness: 1.1 }, active: { scale: 0.96 }, + }); + const self = this; + game.gui.onClick('_mm_play', () => { + for (const fn of self._onPlay) _safeCall(fn, undefined, 'mainMenu.onPlay'); + }); + }, + hide() { + if (!this._active) return; + this._active = false; + this._camCfg = null; + const ids = ['_mm_logo', '_mm_play', '_mm_pn_title', '_mm_pn_0', '_mm_pn_1', '_mm_pn_2', '_mm_pn_3', '_mm_pn_4', '_mm_pn_5', '_mm_pn_6']; + for (const id of ids) { try { game.gui.remove(id); } catch (e) {} } + if (this._curMusic) { _send('audio.stopMusic', {}); this._curMusic = null; } + _send('camera.reset', {}); + _send('player.setInputBlocked', { blocked: false }); + game.hud.setVisible(true); + for (const fn of this._onHide) _safeCall(fn, undefined, 'mainMenu.onHide'); + }, + onShow(fn) { if (typeof fn === 'function') this._onShow.push(fn); }, + onPlay(fn) { if (typeof fn === 'function') this._onPlay.push(fn); }, + onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); }, + isActive() { return this._active; }, + }, + /** + * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат). + * Нужно для игр которые делают свой UI через game.gui.* и не хотят + * чтобы стандартные элементы мешали. + */ + hud: { + /** Скрыть/показать ВСЕ стандартные HUD-элементы. */ + setVisible(visible) { + _send('hud.setVisible', { visible: !!visible }); + }, + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). + * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ + setHotbarVisible(visible) { + _send('hud.setHotbarVisible', { visible: !!visible }); + }, + /** Скрыть/показать только HP-индикатор (полоска жизней). */ + setHpVisible(visible) { + _send('hud.setHpVisible', { visible: !!visible }); + }, + }, + /** + * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). + * + * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. + * + * const m = game.modal.open({ + * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) + * darkenColor: '#000', // цвет затемнения + * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) + * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) + * freezeCamera: true, // камера замирает + * fadeIn: 0.4, // секунды до полного затемнения + * fadeOut: 0.3, + * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) + * spotlightRadius: 120, // пиксели — радиус «прожектора» + * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) + * muteWorld: false, // приглушает ambient/sfx + * cameraOverride: { // фокус камеры на цель + * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, + * }, + * content: { elements: [ // временные GUI поверх модала, удалятся при close + * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, + * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, + * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, + * ]}, + * }); + * game.gui.onClick('fight', () => game.modal.close(m)); + * + * Готовые пресеты: + * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром + * game.modal.lootbox(items, onPick) — открытие лутбокса + * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно + * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет + * + * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. + */ + modal: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) + _onCloseFns: [], + open(opts) { + opts = opts || {}; + const localId = ++this._localSeq; + const replyId = '_mopen_' + localId; + _send('modal.open', { opts, replyId }); + // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event + return localId; + }, + close(modalId) { + // Резолвим локальный id → реальный. Если modalId — локальное число, но + // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал + // одиночный, null закрывает активный. Передавать локальный id нельзя — + // ModalManager.close сверяет его со своим _state.id и молча игнорит. + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; // уже реальный id (строка/число от runtime) + } + _send('modal.close', { modalId: real }); + }, + update(modalId, patch) { + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; + } + _send('modal.update', { modalId: real, patch: patch || {} }); + }, + isOpen() { return !!this._isOpenLocal; }, + onClose(fn) { + if (typeof fn === 'function') this._onCloseFns.push(fn); + }, + + // === Пресеты === + /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ + bossIntro(name, hp, refs, opts) { + opts = opts || {}; + const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; + const buttonText = opts.buttonText || 'В бой!'; + const onStart = opts.onStart; + const elements = [ + { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', + text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', + textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', + text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, + ]; + const m = this.open({ + darken: 0.7, target: 'scene', + blockInput: true, freezeCamera: true, + spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), + cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, + distance: 8, height: 3, fov: 60, duration: 0.5 } : null, + content: { elements }, + }); + const _modal = this; + const _afterTid = ++_timerSeq; + _timers.push({ id: _afterTid, fn: () => { + _send('gui.create', { type: 'button', opts: { + id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', + text: buttonText, + bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 3, borderRadius: 14, + textColor: '#fff', textSize: 22, fontWeight: 900, + textStroke: { color: '#000', width: 2 }, + hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }, localRef: '_boss_start' }); + let _started = false; + _guiClickHandlers['_boss_start'] = [() => { + if (_started) return; + _started = true; + delete _guiClickHandlers['_boss_start']; + _modal.close(m); + if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } + }]; + }, delay: startBtnDelay, elapsed: 0, repeat: false }); + return m; + }, + /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ + lootbox(items, onPick) { + items = Array.isArray(items) ? items.slice(0, 5) : []; + const elements = [ + { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, + borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, + { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', + text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + ]; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + const x = 50 + (i - (items.length - 1) / 2) * 13; + elements.push({ + kind: 'button', id: '_lb_item_' + i, + x: x, y: 50, w: 11, h: 16, anchor: 'center', + text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), + bgColor: it.color || '#3a3a5a', borderRadius: 12, + borderColor: '#ffd700', borderWidth: 2, + textColor: '#fff', textSize: 14, fontWeight: 700, + hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }); + } + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _picked: после первого выбора остальные карточки не должны срабатывать, + // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). + let _picked = false; + for (let i = 0; i < items.length; i++) { + const id = '_lb_item_' + i; + const it = items[i]; + _guiClickHandlers[id] = [() => { + if (_picked) return; + _picked = true; + for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; + _modal.close(m); + if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } + }]; + } + return m; + }, + /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ + dialog(npcName, lines, onDone) { + lines = Array.isArray(lines) ? lines : [String(lines || '')]; + let idx = 0; + const elements = [ + { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, + { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', + text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, + textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', + text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', + textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', + // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», + // на остальных — стрелку «дальше». + text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, + bgColor: '#ffd700', textColor: '#000', borderRadius: 8, + borderColor: '#000', borderWidth: 2, + hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, + animationPreset: 'pulse' }, + ]; + const m = this.open({ + darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, + content: { elements }, + }); + const _modal = this; + // _done защищает от повторного срабатывания: game.modal.close() доигрывает + // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый + // лишний клик снова звал onDone (баг «Диалог завершён ×7»). + let _done = false; + _guiClickHandlers['_dlg_next'] = [() => { + if (_done) return; + idx++; + if (idx < lines.length) { + _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); + // Последняя строка достигнута — превращаем «дальше» в «завершить». + if (idx === lines.length - 1) { + _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); + } + } else { + _done = true; + delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу + _modal.close(m); + if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } + } + }]; + return m; + }, + /** Подтверждение Да/Нет. */ + confirmation(title, body, onYes, onNo) { + const elements = [ + { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', + bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, + { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', + text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, + textColor: '#fff', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', + text: String(body || ''), textSize: 16, fontWeight: 500, + textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + ]; + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал + // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. + let _answered = false; + const _finish = (cb) => { + if (_answered) return; + _answered = true; + delete _guiClickHandlers['_cf_yes']; + delete _guiClickHandlers['_cf_no']; + _modal.close(m); + if (typeof cb === 'function') { try { cb(); } catch (e) {} } + }; + _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; + _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; + return m; + }, + }, + /** + * Экран загрузки (задача 12) — программный mid-game transition. + * const lo = game.loading.show({ progressBar:true, spinner:true }); + * lo.setProgress(0.5); lo.close(); + * await game.loading.transition({ cover:{sceneSnapshot:true}, duration:4 }); + * Хэндл возвращается синхронно (локальный id). Колбэки onSkip/onComplete + * приходят через globalEvent (loadingSkip/loadingComplete) — см. ниже. + */ + loading: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown) + _handlers: new Map(), // localId → { onSkip:[], onComplete:[] } + show(opts) { + opts = opts && typeof opts === 'object' ? opts : {}; + const localId = ++this._localSeq; + const replyId = '_lshow_' + localId; + const h = { onSkip: [], onComplete: [] }; + if (typeof opts.onSkip === 'function') h.onSkip.push(opts.onSkip); + if (typeof opts.onComplete === 'function') h.onComplete.push(opts.onComplete); + this._handlers.set(localId, h); + // Функции нельзя слать в main — вырезаем перед _send. + const safe = {}; + for (const k in opts) { if (typeof opts[k] !== 'function') safe[k] = opts[k]; } + _send('loading.show', { opts: safe, replyId }); + const self = this; + return { + _localId: localId, + setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); }, + setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); }, + setCover(c) { _send('loading.setCover', { localId, cover: c }); }, + close() { _send('loading.close', { localId }); }, + onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); }, + onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); }, + }; + }, + /** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */ + transition(opts) { + opts = opts && typeof opts === 'object' ? { ...opts } : {}; + if (!Number.isFinite(opts.duration) || opts.duration <= 0) opts.duration = 3; + const self = this; + return new Promise((resolve) => { + const h = self.show(opts); + let done = false; + const finish = () => { if (done) return; done = true; resolve(); }; + h.onComplete(finish); + h.onSkip(finish); + }); + }, + }, + /** + * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. + * game.inventory.add({ name: 'Зелье', kind: 'item' }) + * game.inventory.has('Зелье') — по имени или modelTypeId + * game.inventory.remove('Зелье') + * game.inventory.list() — массив предметов + * game.inventory.clear() + */ + inventory: { + /** Добавить предмет. item: { name, kind?, modelTypeId?, params? }. */ + add(item) { + if (!item || typeof item !== 'object') return; + _send('inventory.give', { + kind: item.kind || 'item', + modelTypeId: item.modelTypeId || null, + name: item.name || 'Предмет', + params: item.params || {}, + }); + }, + /** Убрать первый предмет по имени или modelTypeId. */ + remove(nameOrModel) { + if (typeof nameOrModel !== 'string') return; + _send('inventory.remove', { name: nameOrModel, modelTypeId: nameOrModel }); + }, + /** True если предмет с таким именем/modelTypeId есть в инвентаре. */ + has(nameOrModel) { + if (typeof nameOrModel !== 'string') return false; + return (_inventory.slots || []).some(s => + s && (s.name === nameOrModel || s.modelTypeId === nameOrModel)); + }, + /** Массив всех предметов инвентаря (без пустых слотов). */ + list() { + return (_inventory.slots || []).filter(Boolean).map(s => ({ ...s })); + }, + /** Активный предмет (выбранный слот hot-bar) или null. */ + active() { + const s = (_inventory.slots || [])[_inventory.activeIndex]; + return s ? { ...s } : null; + }, + /** Очистить весь инвентарь. */ + clear() { + _send('inventory.clear', {}); + }, + }, + /** + * Игроки комнаты (Фаза 4.3 — мультиплеер). + * В одиночной игре (редактор) — только локальный игрок. + * game.players.me() — я { sessionId, name, position, hp, ... } + * game.players.all() — массив всех игроков (включая меня) + * game.players.count() — сколько игроков + */ + players: { + /** Локальный игрок. */ + me() { + return _players.me ? { ..._players.me } : null; + }, + /** Все игроки комнаты (включая меня). */ + all() { + return (_players.list || []).map(p => ({ ...p })); + }, + /** Сколько игроков в комнате. */ + count() { + return (_players.list || []).length; + }, + /** Найти игрока по sessionId или null. */ + get(sessionId) { + const p = (_players.list || []).find(x => x.sessionId === sessionId); + return p ? { ...p } : null; + }, + }, + /** + * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. + * В одиночной игре работает как локальное хранилище. + * game.room.set('счёт', 10) + * game.room.get('счёт') → 10 + * game.room.onChange('счёт', (v) => game.ui.showText('Счёт: ' + v)) + */ + room: { + /** Установить значение в общем состоянии комнаты. */ + set(key, value) { + if (typeof key !== 'string' || !key) return; + // Оптимистично обновляем локальное зеркало. + _roomState[key] = value; + _send('room.set', { key, value }); + }, + /** Прочитать значение из общего состояния комнаты. */ + get(key) { + if (typeof key !== 'string') return undefined; + return _roomState[key]; + }, + /** Подписаться на изменение ключа общего состояния. fn(value). */ + onChange(key, fn) { + if (typeof key !== 'string' || typeof fn !== 'function') return; + (_roomChangeHandlers[key] = _roomChangeHandlers[key] || []).push(fn); + }, + }, + /** + * Команды (Фаза 4.4) — для командных игр. + * game.teams.create('Красные', '#ff3333') + * game.teams.create('Синие', '#3366ff') + * game.player.setTeam('Красные') + * game.player.team → 'Красные' + */ + teams: { + /** Создать команду с именем и цветом (#hex). */ + create(name, color) { + if (typeof name !== 'string' || !name) return; + _send('teams.create', { + name, + color: typeof color === 'string' ? color : '#888888', + }); + }, + /** Удалить команду. */ + remove(name) { + if (typeof name !== 'string') return; + _send('teams.remove', { name }); + }, + /** Список всех команд — массив { name, color }. */ + all() { + return _teams.map(t => ({ ...t })); + }, + /** Найти команду по имени или null. */ + get(name) { + const t = _teams.find(x => x.name === name); + return t ? { ...t } : null; + }, + }, + /** + * Связи между объектами (Фаза 5, Constraints). + * weld — склейка: объект B движется вместе с A. + * hinge — петля: вращение вокруг оси (двери, рычаги). + * spring — пружина: упругое колебание (батуты). + * Каждый вызов возвращает объект-связь с методами управления. + */ + constraints: { + /** + * Жёстко склеить объект B с A — B следует за A. + * game.constraints.weld(platformRef, crateRef); + */ + weld(refA, refB) { + if (typeof refA !== 'string' || typeof refB !== 'string') return null; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { kind: 'weld', localRef, refA, refB }); + return { + get ref() { return localRef; }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + /** + * Петля: объект вращается вокруг вертикальной оси через pivot. + * opts: { pivotX, pivotZ — точка оси, angle — стартовый угол (°) }. + * Метод setAngle(°) поворачивает объект — для дверей/рычагов. + * const door = game.constraints.hinge(doorRef, { pivotX: 5, pivotZ: 0 }); + * door.setAngle(90); // открыть дверь + */ + hinge(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { + kind: 'hinge', localRef, ref, + pivotX: Number.isFinite(Number(opts.pivotX)) ? Number(opts.pivotX) : undefined, + pivotZ: Number.isFinite(Number(opts.pivotZ)) ? Number(opts.pivotZ) : undefined, + angle: Number(opts.angle) || 0, + }); + return { + get ref() { return localRef; }, + /** Повернуть к углу (градусы) — объект плавно довернётся. */ + setAngle(deg) { + _send('constraint.hingeAngle', { ref: localRef, deg: Number(deg) || 0 }); + }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + /** + * Пружина: объект упруго держится в точке покоя (текущая позиция). + * opts: { stiffness — жёсткость, damping — затухание }. + * Метод push(vx,vy,vz) толкает объект — запускает колебание. + * const trampoline = game.constraints.spring(padRef); + * trampoline.push(0, 12, 0); // подбросить вверх + */ + spring(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { + kind: 'spring', localRef, ref, + stiffness: Number.isFinite(Number(opts.stiffness)) ? Number(opts.stiffness) : undefined, + damping: Number.isFinite(Number(opts.damping)) ? Number(opts.damping) : undefined, + }); + return { + get ref() { return localRef; }, + /** Толкнуть объект (скорость по осям) — запускает колебание. */ + push(vx, vy, vz) { + _send('constraint.springPush', { + ref: localRef, + vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0, + }); + }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + }, + /** + * Эффекты-объекты сцены (Фаза 5.2): лучи и следы. + * beam — светящаяся линия между точками (лазеры, мосты, цепи). + * trail — шлейф за движущимся объектом. + */ + fx: { + /** + * Луч между двумя точками. opts: { from, to — {x,y,z} или ref + * объекта (тогда луч следит за ним); color: '#hex', width }. + * game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' }); + */ + beam(opts) { + opts = opts || {}; + _fxRefSeq++; + const localRef = 'fx:_local_' + _fxRefSeq; + // Задача 08: расширенные опции (текстура/curved/градиент/billboard). + _send('fx.create', { + kind: 'beam', localRef, + from: _normFxPoint(opts.from), to: _normFxPoint(opts.to), + color: typeof opts.color === 'string' ? opts.color : 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 { + get ref() { return localRef; }, + /** Сменить цвет луча. */ + setColor(color) { + _send('fx.beamColor', { ref: localRef, color }); + }, + /** Сменить концы луча ({x,y,z} или ref). */ + setEndpoints(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 }); }, + }; + }, + /** + * Шлейф за объектом. ref — ref-строка объекта. + * opts: { color: '#hex', width, lifetime (сек) }. + * game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 }); + */ + trail(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _fxRefSeq++; + const localRef = 'fx:_local_' + _fxRefSeq; + _send('fx.create', { + kind: 'trail', localRef, ref, + color: typeof opts.color === 'string' ? opts.color : undefined, + width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, + lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined, + }); + return { + get ref() { return localRef; }, + remove() { _send('fx.remove', { ref: localRef }); }, + }; + }, + }, + /** + * Звуки. Два вида: + * 1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click', + * 'hit', 'coin' — game.sound.play('jump'). + * 2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D: + * game.sound.play('sound_1', { at: {x,y,z} }) — 3D в точке + * game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта + * game.sound.play('sound_1', { loop: true }) — зациклить + */ + sound: { + /** + * Проиграть звук. id — пресет ('jump'...) или 'sound_N'. + * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }. + * Для пользовательского звука возвращает объект с методом stop(). + */ + play(id, opts) { + if (typeof id !== 'string' || !id) return null; + opts = opts || {}; + // Встроенный пресет — старый формат {name}. + if (id.indexOf('sound_') !== 0) { + _send('sound.play', { + name: id, + volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, + pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1, + }); + return null; + } + // Пользовательский звук из библиотеки проекта. + _soundRefSeq++; + const localRef = 'sound:_local_' + _soundRefSeq; + _send('sound.play', { + soundId: id, localRef, + volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, + loop: !!opts.loop, + at: (opts.at && Number.isFinite(Number(opts.at.x))) + ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 } + : undefined, + attachRef: typeof opts.attach === 'string' ? opts.attach : undefined, + }); + return { + get ref() { return localRef; }, + /** Остановить этот звук. */ + stop() { _send('sound.stop', { ref: localRef }); }, + }; + }, + }, + /** + * Аудио — GD-музыка и SFX. + * game.audio.playSfx('jump') — короткий звук (jump/death/orb_tap/...) + * game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная) + * game.audio.stopMusic() + * game.audio.setMuted(true) + */ + audio: { + playSfx(name) { + if (typeof name !== 'string') return; + _send('audio.playSfx', { name }); + }, + playMusic(trackId) { + if (typeof trackId !== 'string') return; + _send('audio.playMusic', { trackId }); + }, + stopMusic() { + _send('audio.stopMusic', {}); + }, + setMuted(muted) { + _send('audio.setMuted', { muted: !!muted }); + }, + }, + /** + * Экономика — алмазы и рейтинг через серверные API. + * Все вызовы асинхронные (с callback), потому что идут через HTTP. + * + * game.economy.reward('level_1_first_pass', function(res) { + * // res = { ok, already_awarded, diamonds, rating, ... } + * }); + * game.economy.dailyCheck(function(res) { ... }); + * game.economy.getBalance(function(res) { + * // res = { diamonds, rating } + * }); + */ + economy: { + reward(achievementId, fn) { + if (typeof achievementId !== 'string') return; + const reqId = 'eco_rwd_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.reward', { reqId, achievementId }); + }, + dailyCheck(fn) { + const reqId = 'eco_daily_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.dailyCheck', { reqId }); + }, + getBalance(fn) { + const reqId = 'eco_bal_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.getBalance', { reqId }); + }, + spend(amount, reason, fn) { + const reqId = 'eco_spend_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); + }, + }, + /** + * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox). + * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}), + * затем настраиваются через game.billboard.set/update. + * + * Пресеты (template): + * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены + * - 'shop-purchase' — иконка + название + цена (для покупки) + * - 'banner' — крупный текст + * - 'sign' — простой указатель + * + * Пример (4 таблички-апгрейды): + * const refs = ['vis','range','saws','sprink'].map((kind, i) => { + * return game.scene.spawn('billboard', { + * x: -6 + i*4, y: 3, z: 5, + * template: 'shop-item', + * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2', + * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] }, + * }); + * }); + * game.billboard.onClick(refs[0], 'buy', () => { + * game.ui.showText('Куплено!'); + * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' }); + * }); + */ + billboard: { + /** + * Полная замена контента таблички. Если пресет тот же — мгновенно + * перерисует. Если template другой — пересоздаст текстуру. + * ref — string-ref из game.scene.spawn() или game.scene.findOne() + * opts — { template?, face?, content?, elements? } + */ + set(ref, opts) { + const refStr = _normRef(ref); + if (!refStr || typeof opts !== 'object' || opts == null) return; + _send('billboard.set', { ref: refStr, ...opts }); + }, + /** + * Частичное обновление таблички. + * Две формы: + * 1) update(ref, patch) + * patch — частичный content: { sub, price, title, icon, gradient } + * Применяется к content пресета (shop-item/banner/sign). + * 2) update(ref, elementId, patch) + * Обновляет конкретный элемент по id (только для template:'card' + * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). + * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже + * работают как ключи content. + */ + update(ref, secondArg, thirdArg) { + const refStr = _normRef(ref); + if (!refStr) return; + // 3-аргументная форма: update(ref, elementId, patch) + if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { + _send('billboard.update', { + ref: refStr, + elementId: secondArg, + patch: thirdArg, + }); + return; + } + // 2-аргументная форма: update(ref, patch) + if (typeof secondArg === 'object' && secondArg !== null) { + _send('billboard.update', { ref: refStr, patch: secondArg }); + } + }, + /** + * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; + * в кастомных elements — id из элемента kind='button'). + * ref — string-ref + * buttonId — id кнопки (по умолчанию 'buy') + * fn — () => void + */ + onClick(ref, buttonId, fn) { + if (typeof fn !== 'function') { + fn = buttonId; + buttonId = 'buy'; + } + // Принудительная нормализация ref в plain-string: Instance-Proxy + // не сериализуется через postMessage (DataCloneError). + const refStr = _normRef(ref); + if (!refStr || typeof fn !== 'function') return; + const bid = String(buttonId || 'buy'); + const key = refStr + ':' + bid; + if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; + _billboardClickHandlers[key].push(fn); + _send('billboard.onClick', { ref: refStr, buttonId: bid }); + }, + }, + /** Окружение: небо, туман, время суток. */ + environment: { + /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ + setSkyColor(color) { + if (typeof color !== 'string') return; + _send('environment.setSkyColor', { color }); + }, + /** Установить туман: {enabled, color, density}. */ + setFog(opts) { + if (typeof opts !== 'object' || !opts) return; + _send('environment.setFog', opts); + }, + /** Установить время суток (часы, 0..24). */ + setTimeOfDay(hours) { + const h = Number(hours); + if (!Number.isFinite(h)) return; + _send('environment.setTimeOfDay', { hours: h }); + }, + }, + /** + * Управление режимами ввода — курсор и камера. + * В режиме 'ui' мышь работает как обычный курсор (как в браузере), + * не вращает камеру. Нужно для меню/инвентарей. + */ + input: { + /** + * Установить cursor-режим: 'ui' = курсор-как-в-браузере, + * 'game' = pointer-lock (мышь крутит камеру). + */ + setCursorMode(mode) { + if (mode !== 'ui' && mode !== 'game') return; + _send('input.setCursorMode', { mode }); + }, + /** + * Подписаться на движение мыши в UI-режиме. + * fn(x, y) — нормализованные координаты [0..1] относительно канваса. + */ + onMouseMove(fn) { + if (typeof fn !== 'function') return; + _mouseMoveHandlers.push(fn); + }, + /** Зажатие ЛКМ в UI-режиме. fn(x, y). */ + onMouseDown(fn) { + if (typeof fn !== 'function') return; + _mouseDownHandlers.push(fn); + }, + /** Отпускание ЛКМ. fn(x, y). */ + onMouseUp(fn) { + if (typeof fn !== 'function') return; + _mouseUpHandlers.push(fn); + }, + }, + /** + * Управление приложением целиком: переходы между страницами и т.д. + */ + app: { + /** Выйти из проекта на страницу ленты Kubikon-игр. */ + exit() { + _send('app.exit', {}); + }, + /** Перейти на произвольный URL (внутри сайта). */ + navigate(url) { + if (typeof url !== 'string' || !url) return; + _send('app.navigate', { url }); + }, + }, + /** + * УНИВЕРСАЛЬНОЕ хранилище сохранений (game saves). + * Любая игра может хранить произвольный JSON-стейт игрока. Под каждую + * игру таблицу создавать не нужно — всё через эти эндпоинты. + * + * namespace — строка типа 'progress', 'stats', 'inventory'. Под каждый + * одна запись на (project, user). Макс 20 namespace. + * data — произвольный объект JSON, до 50KB. + * + * Примеры: + * game.save.set('progress', { level: 3, gold: 250 }); + * game.save.get('progress', fn(data) {...}); + * game.save.merge('progress', { increment: { attempts: 1 } }); + * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); + */ + save: { + /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ + get(namespace, fn) { + if (typeof namespace !== 'string' || !namespace) return; + const reqId = 'sg_get_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.get', { reqId, namespace }); + }, + /** Прочитать ВСЕ сохранения юзера. fn(allNamespaces) — { ns1: data, ns2: data }. */ + getAll(fn) { + const reqId = 'sg_all_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.getAll', { reqId }); + }, + /** Записать (полная замена). data — объект/массив. */ + set(namespace, data) { + if (typeof namespace !== 'string' || !namespace) return; + if (data === undefined || data === null) return; + _send('save.set', { namespace, data }); + }, + /** Слияние с существующим. opts: + * { patch: {...}, increment: { key: delta }, max: { key: value } } + * patch — ключи копируются поверх + * increment — атомарный +=delta (нужно для счётчиков, не теряются + * данные если игрок играет с двух устройств) + * max — новое значение пишется только если оно больше старого */ + merge(namespace, opts) { + if (typeof namespace !== 'string' || !namespace) return; + if (!opts || typeof opts !== 'object') return; + _send('save.merge', { + namespace, + patch: opts.patch || {}, + increment: opts.increment || {}, + max: opts.max || {}, + }); + }, + /** Шорткат: атомарный +1 к счётчику. */ + increment(namespace, key, delta) { + if (typeof namespace !== 'string' || typeof key !== 'string') return; + const d = Number(delta); + const inc = {}; + inc[key] = Number.isFinite(d) ? d : 1; + _send('save.merge', { namespace, patch: {}, increment: inc, max: {} }); + }, + /** Лидерборд по ключу. order='asc' (меньше=лучше) | 'desc' (больше=лучше). + * fn(entries) — массив { rank, user_id, username, value }. */ + leaderboard(namespace, key, order, fn) { + if (typeof namespace !== 'string' || typeof key !== 'string') return; + const reqId = 'sg_lb_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.leaderboard', { + reqId, namespace, key, + order: order === 'asc' ? 'asc' : 'desc', + }); + }, + }, + log(...args) { + const parts = args.map(a => { + if (typeof a === 'string') return a; + if (typeof a === 'number' || typeof a === 'boolean') return String(a); + if (a == null) return String(a); + try { return JSON.stringify(a); } catch (e) { return '[object]'; } + }); + _send('log', { level: 'info', text: parts.join(' ') }); + }, + + /** + * Случайное число. + * random() → 0..1 + * random(max) → 0..max + * random(min, max) → min..max + * random(min, max, true) → целое min..max включительно + */ + random(min, max, integer) { + if (min === undefined) return Math.random(); + if (max === undefined) { max = min; min = 0; } + const a = Number(min), b = Number(max); + if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; + if (integer) { + const lo = Math.ceil(Math.min(a, b)); + const hi = Math.floor(Math.max(a, b)); + return Math.floor(Math.random() * (hi - lo + 1)) + lo; + } + return a + Math.random() * (b - a); + }, + + // Форматирование чисел/времени/денег для UI. Портировано из студии + // (задача 11 — игра «Мой завод» использует game.format.money). + format: { + time(seconds, fmt) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const p2 = (n) => String(n).padStart(2, '0'); + if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s); + if (fmt === 'mm:ss') { + const tm = Math.floor(sec / 60); + return p2(tm) + ':' + p2(s); + } + // auto + if (h > 0) return h + 'ч ' + m + 'м'; + if (m > 0) return m + 'м ' + s + 'с'; + return s + 'с'; + }, + number(n, fmt) { + n = Number(n) || 0; + if (fmt === 'percent') return Math.round(n * 100) + '%'; + if (fmt === 'short') { + const abs = Math.abs(n); + if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B'; + if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M'; + if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K'; + return String(Math.round(n)); + } + // comma — пробелы-разделители тысяч (русский стиль), без regex. + const str = String(Math.abs(Math.round(n))); + let out = ''; + for (let i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 === 0) out += ' '; + out += str[i]; + } + return (n < 0 ? '-' : '') + out; + }, + money(amount, unit) { + const num = this.number(amount, 'comma'); + const u = (unit === 'rubles' || unit === undefined) + ? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов') + : unit; + return num + ' ' + u; + }, + duration(seconds) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов'); + if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут'); + return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд'); + }, + // Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов). + _plural(n, one, few, many) { + n = Math.abs(n) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + }, + }, + + /** + * Расстояние между двумя точками или объектами. + * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). + */ + distance(a, b) { + const pa = _resolveToPos(a); + const pb = _resolveToPos(b); + if (!pa || !pb) return Infinity; + const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + }, + + /** + * Отправить именованное сообщение всем скриптам (включая себя). + * Используется для общения между скриптами в разных sandbox'ах. + * game.broadcast('checkpoint', { num: 2 }); + */ + broadcast(name, data) { + if (typeof name !== 'string' || !name) return; + _send('broadcast', { name, data: data == null ? null : data }); + }, + + /** + * Подключить скрипт-модуль и получить его exports. + * Модуль — другой скрипт проекта; в нём пишут в объект exports: + * // скрипт "math_utils": + * exports.add = (a, b) => a + b; + * // обычный скрипт: + * const m = game.require('math_utils'); + * game.log(m.add(2, 3)); // 5 + * Модуль исполняется один раз, дальше отдаётся из кеша. + */ + require(name) { + if (typeof name !== 'string' || !name) return null; + if (_moduleCache[name]) return _moduleCache[name]; + const code = _moduleCode[name]; + if (code == null) { + _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name }); + return null; + } + try { + const exportsObj = {}; + // модуль видит game и exports; повторный require внутри модуля тоже работает + const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code); + moduleFn(game, exportsObj); + _moduleCache[name] = exportsObj; + return exportsObj; + } catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err), + }); + return null; + } + }, + + /** + * Подписаться на сообщение. + * game.onMessage('checkpoint', (data) => { ... }); + */ + onMessage(name, fn) { + if (typeof name !== 'string' || !name) return; + if (typeof fn !== 'function') return; + (_messageHandlers[name] = _messageHandlers[name] || []).push(fn); + }, + + /** Зажать значение между min и max. */ + clamp(value, min, max) { + const v = Number(value); + const lo = Number(min), hi = Number(max); + if (!Number.isFinite(v)) return 0; + if (v < lo) return lo; + if (v > hi) return hi; + return v; + }, + + /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */ + lerp(a, b, t) { + const na = Number(a), nb = Number(b), nt = Number(t); + return na + (nb - na) * nt; + }, + /** + * game.placement — drag-and-drop размещение объектов (задача 11). + * Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором + * → ЛКМ ставит». См. 11_placement_mode.md. + * + * game.placement.start('crate', { + * previewType: 'model:crate', surfaceMode: 'ground', grid: 1, + * cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'), + * showArrowFrom: 'player', showZoneOutline: true, chainPlace: true, + * }); + * game.placement.onPlace(({ itemKey, position, rotationY }) => { ... }); + */ + placement: { + /** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */ + start(itemKey, opts) { + if (typeof itemKey !== 'string' || !itemKey) return null; + const o = opts && typeof opts === 'object' ? opts : {}; + // targetZone может прийти как ref-объект findOne — нормализуем в строку. + const out = { itemKey, opts: { ...o } }; + if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone; + _send('placement.start', out); + return itemKey; + }, + /** Отменить активный режим (как ПКМ/Esc). */ + cancel() { _send('placement.cancel', {}); }, + /** Поставить на текущей позиции (как ЛКМ). */ + confirm() { _send('placement.confirm', {}); }, + /** Повернуть preview на N градусов (по умолчанию rotationStep). */ + rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); }, + /** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */ + onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); }, + /** fn() — режим отменён игроком. */ + onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); }, + /** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */ + onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); }, + }, + + /** + * game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту + * → onSlotClick(item) (обычно автор зовёт game.placement.start внутри). + * Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance). + * + * game.inventoryUi.create({ + * items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }], + * position: 'bottom', showCost: true, showCurrency: 'rubles', + * onSlotClick: (item) => game.placement.start(item.key, {...}), + * }); + */ + inventoryUi: { + /** Создать панель слотов. См. 11_placement_mode.md §2.7. */ + create(opts) { + const o = opts && typeof opts === 'object' ? opts : {}; + const items = Array.isArray(o.items) ? o.items : []; + if (typeof o.onSlotClick === 'function') { + // Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}. + _invUiSlotClickHandlers.push(o.onSlotClick); + } + _send('inventoryUi.create', { + items: items.map(it => ({ + key: String(it.key || ''), + name: String(it.name || ''), + icon: it.icon || '', + cost: Number(it.cost) || 0, + modelType: it.modelType || '', + })), + position: o.position || 'bottom', + slotSize: Number(o.slotSize) || 80, + spacing: Number(o.spacing) || 4, + showCost: o.showCost !== false, + showCurrency: o.showCurrency || '', + }); + }, + /** Обновить баланс валюты (для авто-серых слотов). */ + setBalance(currency, amount) { + _send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 }); + }, + /** Скрыть/удалить панель. */ + remove() { _send('inventoryUi.remove', {}); }, + }, + +}; + +/** + * Пересечение луча с AABB (в локальных координатах бокса, центр в 0). + * Возвращает расстояние t до точки входа или null если не пересекает. + * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.), + * (hw,hh,hd)=полуразмеры бокса. + */ +function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) { + let tmin = -Infinity, tmax = Infinity; + const axes = [ + [ox, dx, hw], [oy, dy, hh], [oz, dz, hd], + ]; + for (const [o, d, h] of axes) { + if (Math.abs(d) < 1e-9) { + // луч параллелен слою — мимо если начало вне границ + if (o < -h || o > h) return null; + } else { + let t1 = (-h - o) / d; + let t2 = (h - o) / d; + if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return null; + } + } + // tmin<0 значит начало внутри бокса — берём 0 + return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null); +} + +/** Резолв позиции из {x,y,z} или ref-строки. */ +function _resolveToPos(arg) { + if (!arg) return null; + if (typeof arg === 'string') { + for (const b of _sceneIndex.blocks) if (b.ref === arg) return { x: b.x, y: b.y, z: b.z }; + for (const m of _sceneIndex.models) if (m.ref === arg) return { x: m.x, y: m.y, z: m.z }; + for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z }; + return null; + } + if (typeof arg === 'object' && Number.isFinite(arg.x)) { + return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 }; + } + return null; +} + +// === Обработчики сообщений из main === +self.onmessage = (e) => { + const { cmd, payload } = e.data || {}; + if (cmd === 'init') { + // payload: { code, target?, selfPosition?, modules? } + if (payload && payload.target) { + _target = payload.target; + if (payload.selfPosition) _selfPosition = payload.selfPosition; + _selfApi = _buildSelfApi(); + } + // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require + if (payload && payload.modules && typeof payload.modules === 'object') { + _moduleCode = payload.modules; + } + // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, + // чтобы findOne()/find() работали в синхронном теле скрипта на старте + // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). + if (payload && payload.initialScene && typeof payload.initialScene === 'object') { + const s = payload.initialScene; + _sceneIndex = { + blocks: s.blocks || [], + models: s.models || [], + primitives: s.primitives || [], + }; + } + try { + // exports передаём всегда — скрипт может быть и модулем (пишет в + // exports), и обычным скриптом (игнорирует его). Без этого + // скрипт-модуль падает с 'exports is not defined' при прямом запуске. + const exportsObj = {}; + const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); + userFn(game, exportsObj); + _send('ready', null); + } catch (err) { + _send('log', { level: 'error', text: 'Ошибка скрипта: ' + (err && err.message ? err.message : err) }); + _send('ready', null); + } + } else if (cmd === 'tick') { + const dt = payload && typeof payload.dt === 'number' ? payload.dt : 0; + if (payload && payload.player) { + const pp = payload.player; + if (pp.position) _playerState.position = pp.position; + if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; + if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; + if (pp.forward) _playerState.forward = pp.forward; + if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; + if (typeof pp.hp === 'number') _playerState.hp = pp.hp; + if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; + // Кубикон Dash: направление гравитации (+1 / -1). + if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; + if (typeof pp.state === 'string') _playerState.state = pp.state; + if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; + } + if (payload && payload.selfPosition) { + _selfPosition = payload.selfPosition; + } + if (payload && Array.isArray(payload.mobs)) { + _mobs = payload.mobs; + } + if (payload && Array.isArray(payload.npcs)) { + _npcs = payload.npcs; + } + if (payload && payload.inventory && typeof payload.inventory === 'object') { + _inventory = payload.inventory; + } + if (payload && payload.players && typeof payload.players === 'object') { + _players = payload.players; + } + if (payload && payload.roomState && typeof payload.roomState === 'object') { + _roomState = payload.roomState; + } + if (payload && Array.isArray(payload.teams)) { + _teams = payload.teams; + } + for (const fn of _tickHandlers) { + _safeCall(fn, dt, 'onTick'); + } + // Таймеры game.after / game.every — копим dt, срабатываем при достижении delay. + // Итерируем по копии: callback может вызвать game.after/cancel и изменить _timers. + if (_timers.length > 0 && dt > 0) { + const due = []; + for (const t of _timers) { + t.elapsed += dt; + if (t.elapsed >= t.delay) due.push(t); + } + for (const t of due) { + if (t.repeat) { + // отнимаем delay (не сбрасываем в 0) — равномерный интервал без дрейфа + t.elapsed -= t.delay; + } else { + const i = _timers.indexOf(t); + if (i >= 0) _timers.splice(i, 1); + } + _safeCall(t.fn, undefined, t.repeat ? 'every' : 'after'); + } + } + } else if (cmd === 'state') { + if (payload && payload.player) { + const pp = payload.player; + if (pp.position) _playerState.position = pp.position; + if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; + if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; + if (pp.forward) _playerState.forward = pp.forward; + if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; + if (typeof pp.hp === 'number') _playerState.hp = pp.hp; + if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; + // Кубикон Dash: направление гравитации (+1 / -1). + if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; + if (typeof pp.state === 'string') _playerState.state = pp.state; + if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; + } + if (payload && payload.selfPosition) { + _selfPosition = payload.selfPosition; + } + if (payload && Array.isArray(payload.mobs)) { + _mobs = payload.mobs; + } + if (payload && Array.isArray(payload.npcs)) { + _npcs = payload.npcs; + } + if (payload && payload.inventory && typeof payload.inventory === 'object') { + _inventory = payload.inventory; + } + if (payload && payload.players && typeof payload.players === 'object') { + _players = payload.players; + } + if (payload && payload.roomState && typeof payload.roomState === 'object') { + _roomState = payload.roomState; + } + if (payload && Array.isArray(payload.teams)) { + _teams = payload.teams; + } + } else if (cmd === 'event') { + // payload: { type, ...data } + const t = payload?.type; + if (t === 'click') { + // self.onClick — только если есть target и target совпал + for (const fn of _selfClickHandlers) _safeCall(fn, payload, 'self.onClick'); + } else if (t === 'touch') { + for (const fn of _selfTouchHandlers) _safeCall(fn, payload, 'self.onTouch'); + } else if (t === 'untouch') { + for (const fn of _selfUntouchHandlers) _safeCall(fn, payload, 'self.onUntouch'); + } else if (t === 'interact') { + for (const fn of _selfInteractHandlers) _safeCall(fn, payload, 'self.onInteract'); + } + } else if (cmd === 'globalEvent') { + // payload: { type, ...data } — глобальные события (всем sandbox'ам) + const t = payload?.type; + if (t === 'click') { + for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); + } else if (t === 'mouseMove') { + for (const fn of _mouseMoveHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'mouseDown') { + for (const fn of _mouseDownHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'mouseUp') { + for (const fn of _mouseUpHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'playerTouch') { + for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); + } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { + // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). + const b = _instTouchHandlers.get(payload && payload.ref); + if (b) { + const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; + for (const fn of list) _safeCall(fn, payload, 'inst.' + t); + } + } else if (t === 'instInteract') { + const b = _instTouchHandlers.get(payload && payload.ref); + if (b) for (const fn of b.interact) _safeCall(fn, payload, 'inst.onInteract'); + } else if (t === 'hpChange') { + for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); + } else if (t === 'mobKilled') { + for (const fn of _mobKilledHandlers) _safeCall(fn, payload, 'onMobKilled'); + } else if (t === 'npcDeath') { + // payload: { npcId, position } + const npcId = payload.npcId; + const ev = { id: npcId, position: payload.position }; + // Глобальные подписчики game.onNpcDeath(fn). + for (const fn of _globalNpcDeathHandlers) _safeCall(fn, ev, 'onNpcDeath'); + // Адресные подписки npc.onDeath — по числовому id ИЛИ по + // локальному ref, который при спавне привязали к этому id. + const keys = [String(npcId)]; + for (const [lref, real] of Object.entries(_npcLocalToReal)) { + if (real === npcId) keys.push(lref); + } + for (const key of keys) { + const arr = _npcDeathHandlers[key] || []; + for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); + } + } else if (t === 'toolUse') { + // payload: { tool: {kind, modelTypeId, name}, point, target } + const ev = { + tool: payload.tool || null, + point: payload.point || null, + target: payload.target || null, + }; + for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); + } else if (t === 'cutsceneDone') { + // Катсцена камеры завершилась (Фаза 5.7). + for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); + } else if (t === 'vehicleEnter') { + const vref = 'vehicle:' + (payload && payload.vehicleId); + for (const fn of _vehicleEnterHandlers) _safeCall(fn, vref, 'onVehicleEnter'); + } else if (t === 'vehicleExit') { + const vref = 'vehicle:' + (payload && payload.vehicleId); + for (const fn of _vehicleExitHandlers) _safeCall(fn, vref, 'onVehicleExit'); + } else if (t === 'playerJoin') { + // payload: { sessionId, name } + for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); + } else if (t === 'playerLeave') { + for (const fn of _playerLeaveHandlers) _safeCall(fn, payload, 'onPlayerLeave'); + } else if (t === 'roomChange') { + // payload: { key, value } — изменилось общее состояние комнаты. + const arr = _roomChangeHandlers[payload.key] || []; + for (const fn of arr) _safeCall(fn, payload.value, 'room.onChange:' + payload.key); + } else if (t === 'mpMessage') { + // payload: { from, name, data } — адресное сообщение. + const arr = _mpMessageHandlers[payload.name] || []; + for (const fn of arr) { + _safeCall(fn, { from: payload.from, data: payload.data }, + 'onMessage:' + payload.name); + } + } else if (t === 'playerDied') { + for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied'); + } else if (t === 'playerJump') { + for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump'); + } else if (t === 'playerLand') { + for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand'); + } else if (t === 'keydown') { + const key = String(payload.key || '').toLowerCase(); + const arr = _globalKeyDownHandlers[key] || []; + for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key); + const wild = _globalKeyDownHandlers['*'] || []; + for (const fn of wild) _safeCall(fn, payload, 'onKey(*)'); + } else if (t === 'keyup') { + const key = String(payload.key || '').toLowerCase(); + const arr = _globalKeyUpHandlers[key] || []; + for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key); + const wild = _globalKeyUpHandlers['*'] || []; + for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)'); + } else if (t === 'message') { + const name = String(payload.name || ''); + const arr = _messageHandlers[name] || []; + for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); + } else if (t === 'guiClick') { + const id = String(payload.id || ''); + const localId = payload.localId != null ? String(payload.localId) : null; + // Собираем handlers по id, по локальному ref и по имени элемента — + // скрипт мог подписаться любым из этих ключей. + // _matched защищает от двойного вызова если несколько ключей ведут + // к одному и тому же массиву handlers. + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiClickHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); + for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); + } + } else if (t === 'guiSubmit') { + const id = String(payload.id || ''); + const localId = payload.localId != null ? String(payload.localId) : null; + const val = payload.value != null ? String(payload.value) : ''; + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiSubmitHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); + for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); + } + } else if (t === 'billboardClick') { + // payload: { ref, button } — клик по кнопке 3D-таблички. + // Ищем handlers и по реальному ref (primitive:NN), и по локальному + // ref если такой есть (на случай если скрипт подписался по + // локальному ref от scene.spawn). + const realRef = String(payload.ref || ''); + const button = String(payload.button || 'buy'); + const tryKeys = [realRef + ':' + button]; + // Если есть локальный ref, ведущий к этому real — тоже попробуем + // (скрипт мог подписаться на ref сразу после game.scene.spawn, + // когда ref был ещё локальным _local_N). + for (const [local, real] of Object.entries(_spawnLocalToReal || {})) { + if (real === realRef) tryKeys.push(local + ':' + button); + } + for (const key of tryKeys) { + const arr = _billboardClickHandlers[key] || []; + for (const fn of arr) _safeCall(fn, { ref: realRef, button }, + 'billboard.onClick:' + key); + } + } else if (t === 'modalOpened') { + // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту + // локальный id (чтобы он мог его сохранить и звать close/update); здесь + // запоминаем маппинг local→real, иначе close(m) уходит с локальным id + // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); + if (Number.isFinite(localId) && payload.modalId != null) { + mm._localToReal.set(localId, payload.modalId); + mm._isOpenLocal = true; + } + } + } catch (e) {} + } else if (t === 'modalClosed') { + // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm) { + mm._isOpenLocal = false; + const cbs = mm._onCloseFns || []; + for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); + } + } catch (e) {} + } else if (t === 'loadingShown') { + // Задача 12: реальный loadingId от runtime — маппим local→real. + try { + const lo = (typeof game !== 'undefined') && game.loading; + if (lo && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_lshow_/, '')); + if (Number.isFinite(localId) && payload.loadingId != null) { + lo._localToReal.set(localId, payload.loadingId); + } + } + } catch (e) {} + } else if (t === 'loadingSkip' || t === 'loadingComplete') { + // Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков. + try { + const lo = (typeof game !== 'undefined') && game.loading; + const real = payload && payload.loadingId; + if (lo && real != null) { + for (const [local, r] of lo._localToReal) { + if (r === real) { + const h = lo._handlers.get(local); + if (h) { + const arr = t === 'loadingSkip' ? h.onSkip : h.onComplete; + for (const fn of arr) _safeCall(fn, undefined, 'loading.' + t); + } + } + } + } + } catch (e) {} + } else if (t === 'skinChanged') { + // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. + const slug = payload && payload.slug; + if (slug) { + _currentSkin = slug; + for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); + } + } else if (t === 'skinUnlocked') { + const slug = payload && payload.slug; + if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + } else if (t === 'placeConfirm') { + const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY }; + for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace'); + } else if (t === 'placeCancel') { + for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel'); + } else if (t === 'placeMove') { + const ev = { position: payload.position, valid: !!payload.valid }; + for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove'); + } else if (t === 'invUiSlotClick') { + const item = payload.item || { key: payload.key }; + for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick'); + } + } else if (cmd === 'sceneSnapshot') { + // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } + if (payload) { + _sceneIndex = { + blocks: payload.blocks || [], + models: payload.models || [], + primitives: payload.primitives || [], + }; + // детект дельт и эмит events для Instance (если кто-то подписан). + try { _detectSnapshotDeltas(); } catch (e) {} + } + } else if (cmd === 'guiSnapshot') { + // payload: массив всех GUI-элементов (для game.gui.find/get/all) + _guiIndex = Array.isArray(payload) ? payload : []; + } else if (cmd === 'skinsSnapshot') { + // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } + if (payload && typeof payload === 'object') { + _skinsIndex = Array.isArray(payload.all) ? payload.all : []; + _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; + _currentSkin = payload.current || _currentSkin; + if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; + } + } else if (cmd === 'dataSnapshot') { + // payload: { ref: { key: value } } — атрибуты всех объектов + _dataIndex = payload && typeof payload === 'object' ? payload : {}; + } else if (cmd === 'terrainHeightmap') { + // payload: { origin:{x,z}, step, cols, rows, heights:[] } + // Карта высот гладкого ландшафта для game.scene.surfaceY. + _terrainHM = payload || null; + } else if (cmd === 'saveResponse') { + // payload: { reqId, result } + const reqId = payload && payload.reqId; + const cb = reqId && _saveCallbacks[reqId]; + if (cb) { + delete _saveCallbacks[reqId]; + try { cb(payload.result); } catch (e) {} + } + } else if (cmd === 'economyResponse') { + // payload: { reqId, result } + const reqId = payload && payload.reqId; + const cb = reqId && _economyCallbacks[reqId]; + if (cb) { + delete _economyCallbacks[reqId]; + try { cb(payload.result); } catch (e) {} + } + } else if (cmd === 'tweenDone') { + // payload: { tweenId } — твин доиграл, зовём onDone + const tid = payload && payload.tweenId; + const cb = tid != null && _tweenCallbacks[tid]; + if (cb) { + delete _tweenCallbacks[tid]; + _safeCall(cb, undefined, 'tween.onDone'); + } + } else if (cmd === 'npcSpawned') { + // payload: { localRef, npcId } — async-спавн NPC завершён. + // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал. + if (payload && payload.localRef != null) { + _npcLocalToReal[payload.localRef] = payload.npcId; + } + } else if (cmd === 'spawnResolved') { + // payload: { localRef, realRef } — scene.spawn создал объект. + // Запоминаем маппинг для getPosition и т.п. + if (payload && payload.localRef && payload.realRef) { + _spawnLocalToReal[payload.localRef] = payload.realRef; + } + } else if (cmd === 'stop') { + _tickHandlers = []; + _timers = []; + _selfClickHandlers = []; + _selfTouchHandlers = []; + _selfUntouchHandlers = []; + _selfInteractHandlers = []; + _instTouchHandlers.clear(); + _globalKeyDownHandlers = {}; + _globalKeyUpHandlers = {}; + _globalClickHandlers = []; + _globalTouchHandlers = []; + _mouseMoveHandlers = []; + _mouseDownHandlers = []; + _mouseUpHandlers = []; + _mobKilledHandlers = []; + _hpChangeHandlers = []; + _playerDiedHandlers = []; + _playerJumpHandlers = []; + _playerLandHandlers = []; + _messageHandlers = {}; + _guiClickHandlers = {}; + _guiSubmitHandlers = {}; + _npcDeathHandlers = {}; + _globalNpcDeathHandlers = []; + _npcLocalToReal = {}; + _spawnLocalToReal = {}; + _npcs = []; + _toolUseHandlers = []; + _inventory = { slots: [], activeIndex: 0 }; + _players = { me: null, list: [] }; + _roomState = {}; + _playerJoinHandlers = []; + _playerLeaveHandlers = []; + _cutsceneDoneHandlers = []; + _mpMessageHandlers = {}; + _roomChangeHandlers = {}; + _teams = []; + _constraintRefSeq = 0; + _fxRefSeq = 0; + _soundRefSeq = 0; + } +}; + +_send('boot', null); +`; + +/** + * Создаёт URL Worker-кода для new Worker(url). + */ +export function getWorkerSourceUrl() { + const blob = new Blob([SOURCE], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} diff --git a/src/engine/ShopInventoryUi.js b/src/engine/ShopInventoryUi.js new file mode 100644 index 0000000..0f755ff --- /dev/null +++ b/src/engine/ShopInventoryUi.js @@ -0,0 +1,132 @@ +/** + * ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * + * Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover. + * Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри + * game.placement.start(...). Слот серый и некликабельный, если валюты мало + * (показывается, когда заданы showCurrency + текущий баланс через setBalance). + * + * Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с + * иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к + * родителю canvas, абсолютным позиционированием. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI). +const SLOT_ICONS = { + crate: '', + plant: '', + oven: '', + coin: '$', + box: '', +}; + +function iconSvg(name) { + return SLOT_ICONS[name] || SLOT_ICONS.box; +} + +export class ShopInventoryUi { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this.items = []; + this.balance = {}; // currency → amount + this.currency = ''; + this.showCost = true; + this._onSlotClick = null; + this._slotEls = []; + } + + create(opts, onSlotClick) { + this.remove(); + this.items = Array.isArray(opts.items) ? opts.items : []; + this.currency = opts.showCurrency || ''; + this.showCost = opts.showCost !== false; + this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null; + + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + // Контейнер должен быть position:relative чтобы absolute-панель легла поверх. + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const pos = opts.position || 'bottom'; + const slotSize = Number(opts.slotSize) || 80; + const spacing = Number(opts.spacing) || 4; + + const root = document.createElement('div'); + root.className = 'kbn-shop-inv'; + const sideStyle = { + bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`, + top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`, + left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + }[pos] || ''; + root.style.cssText = + `position:absolute;display:flex;gap:${spacing}px;z-index:40;` + + `padding:8px;border-radius:14px;` + + `background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` + + `box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle; + + this.items.forEach((it, idx) => { + const slot = document.createElement('button'); + slot.type = 'button'; + slot.dataset.key = it.key; + slot.style.cssText = + `width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` + + `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` + + `cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` + + `background:linear-gradient(180deg,#3a4a66,#26324a);` + + `transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`; + slot.innerHTML = + `${iconSvg(it.icon)}` + + `${it.name || ''}` + + (this.showCost && it.cost + ? `${it.cost}${this.currency ? ' ' + this._curShort() : ''}` + : ''); + slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } }; + slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; }; + slot.onclick = () => { + if (slot.disabled) return; + if (this._onSlotClick) this._onSlotClick(it); + }; + this._slotEls[idx] = slot; + root.appendChild(slot); + }); + + parent.appendChild(root); + this.root = root; + this._refreshAffordability(); + } + + _curShort() { + const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' }; + return map[this.currency] || this.currency; + } + + /** Обновить баланс валюты — слоты дороже баланса станут серыми. */ + setBalance(currency, amount) { + if (currency) this.balance[currency] = Number(amount) || 0; + this._refreshAffordability(); + } + + _refreshAffordability() { + if (!this.currency) return; // без валюты все слоты активны + const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity; + this.items.forEach((it, idx) => { + const slot = this._slotEls[idx]; + if (!slot) return; + const afford = (Number(it.cost) || 0) <= bal; + slot.disabled = !afford; + slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)'; + slot.style.cursor = afford ? 'pointer' : 'not-allowed'; + slot.style.opacity = afford ? '1' : '0.7'; + }); + } + + remove() { + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } + this._slotEls = []; + } + + dispose() { this.remove(); this._onSlotClick = null; } +} diff --git a/src/engine/TerrainManager.js b/src/engine/TerrainManager.js index 24ba1f9..2865807 100644 --- a/src/engine/TerrainManager.js +++ b/src/engine/TerrainManager.js @@ -514,6 +514,10 @@ export class TerrainManager { const mat = new StandardMaterial(name, this.scene); // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. mat.specularColor = new Color3(0, 0, 0); + // 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю). + // backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю + // по depth. Прозрачным (water/glacier) culling оставляем. См. studio. + mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true; // Ambient ставим в белый, чтобы hemisphere-light освещал материал // с любой стороны (иначе нижние/тыловые грани выходят серыми, что // особенно заметно на светло-бежевом песке — он становится серым). @@ -543,6 +547,12 @@ export class TerrainManager { mat.diffuseTexture.hasAlpha = true; mat.useAlphaFromDiffuseTexture = true; mat.alpha = def.alpha; + } else { + // RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend → + // воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio. + mat.diffuseTexture.hasAlpha = false; + mat.useAlphaFromDiffuseTexture = false; + mat.transparencyMode = 0; } if (Array.isArray(def.emissive)) { mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); diff --git a/src/engine/VehicleHud.js b/src/engine/VehicleHud.js new file mode 100644 index 0000000..adcaf0f --- /dev/null +++ b/src/engine/VehicleHud.js @@ -0,0 +1,95 @@ +/** + * VehicleHud — HUD водителя (задача 14): круглый спидометр со стрелкой, + * передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как + * ShopInventoryUi). Показывается пока игрок за рулём. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ +export class VehicleHud { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this.needle = null; + this.speedText = null; + this.gearText = null; + this._maxKmh = 80; + } + + show(maxKmh) { + this.remove(); + this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10); + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const root = document.createElement('div'); + root.className = 'kbn-veh-hud'; + root.style.cssText = + 'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' + + 'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;'; + + // SVG-циферблат. + const R = 70, CX = 80, CY = 80; + const startA = 135, endA = 405; // дуга 270° + const ticks = []; + const N = 8; + for (let i = 0; i <= N; i++) { + const a = (startA + (endA - startA) * i / N) * Math.PI / 180; + const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4); + const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14); + ticks.push(``); + const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4; + const val = Math.round(this._maxKmh * i / N); + ticks.push(`${val}`); + } + root.innerHTML = + `` + + `` + + ticks.join('') + + `` + + `` + + `0` + + `км/ч` + + `N` + + ``; + parent.appendChild(root); + this.root = root; + this.needle = root.querySelector('#kbn-veh-needle'); + this.speedText = root.querySelector('#kbn-veh-speed'); + this.gearText = root.querySelector('#kbn-veh-gear'); + this._CX = CX; this._CY = CY; + + // Подсказки клавиш справа снизу. + const keys = document.createElement('div'); + keys.className = 'kbn-veh-keys'; + keys.style.cssText = + 'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' + + 'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' + + 'text-shadow:0 1px 3px rgba(0,0,0,0.7);'; + keys.innerHTML = '
WASD — руль
V — камера
E — выйти
'; + parent.appendChild(keys); + this._keys = keys; + } + + /** Обновить стрелку/число/передачу. speed — м/с (signed). */ + update(speedMs) { + if (!this.needle) return; + const kmh = Math.abs(speedMs) * 3.6; + const frac = Math.max(0, Math.min(1, kmh / this._maxKmh)); + const ang = -135 + 270 * frac; // -135°..+135° + this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`); + if (this.speedText) this.speedText.textContent = String(Math.round(kmh)); + if (this.gearText) { + const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D'); + this.gearText.textContent = g; + this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0'); + } + } + + remove() { + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } + if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; } + this.needle = this.speedText = this.gearText = null; + } + + dispose() { this.remove(); } +} diff --git a/src/engine/VehicleManager.js b/src/engine/VehicleManager.js new file mode 100644 index 0000000..aeb7e57 --- /dev/null +++ b/src/engine/VehicleManager.js @@ -0,0 +1,249 @@ +import { Vector3, TransformNode } from '@babylonjs/core'; + +/** + * VehicleManager — система транспорта (задача 14, фаза V1 аркадная + V2 параметры). + * + * Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) + + * 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ: + * speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer + * (масштаб от скорости — нет вращения на месте); коллизия с миром через + * physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с + * другими машинами НЕ сталкиваются (V1) — только chassis с миром. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +const DEFAULT_PARAMS = { + mass: 1200, + enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с. + maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров + turnSpeed: 1.8, // рад/с при полной скорости + brake: 26, // замедление при тормозе/реверсе + drive: 'rwd', +}; + +export class VehicleManager { + constructor(scene3d) { + this.s = scene3d; + this.scene = scene3d.scene; + this.vehicles = new Map(); // id → veh + this._seq = 0; + } + + get _physics() { return this.s.physics; } + get _models() { return this.s.modelManager; } + + /** + * Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }. + * Возвращает Promise. + */ + async spawn(opts) { + opts = opts || {}; + const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0; + // Идемпотентность: если машина с такой позицией уже есть — не плодим + // (защита от двойного выполнения скрипта спавна → дубли машин). + for (const v of this.vehicles.values()) { + if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id; + } + const id = ++this._seq; + const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0; + const yaw = Number(opts.rotationY) || 0; + const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) }; + const modelType = opts.model || 'car-sedan'; + + // chassis-узел — родитель кузова и колёс. + const chassisNode = new TransformNode(`vehicle_${id}`, this.scene); + chassisNode.position = new Vector3(x, y, z); + chassisNode.rotation = new Vector3(0, yaw, 0); + + const veh = { + id, name: opts.name || 'Машина', params, + spawnX: x, spawnZ: z, // для дедупа повторного спавна + chassisNode, bodyInstanceId: null, wheels: [], + pos: new Vector3(x, y, z), yaw, vy: 0, + speed: 0, steerAngle: 0, + half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова + throttle: 0, steer: 0, handbrake: false, + driver: null, + handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] }, + ref: opts.ref || null, + }; + this.vehicles.set(id, veh); + + // Кузов (GLB Kenney car-kit). + try { + const bodyId = await this._models.addInstance(modelType, x, y, z, yaw); + veh.bodyInstanceId = bodyId; + const inst = this._models.instances.get(bodyId); + if (inst && inst.rootMesh) { + // Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга + // (в мировых координатах, кузов ещё в (x,y,z)). + try { + const bb = inst.rootMesh.getHierarchyBoundingVectors(true); + veh.half = { + w: Math.max(0.6, (bb.max.x - bb.min.x) / 2), + h: Math.max(0.4, (bb.max.y - bb.min.y) / 2), + d: Math.max(1.0, (bb.max.z - bb.min.z) / 2), + }; + // Насколько низ кузова ниже точки спавна y — чтобы посадить + // кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле, + // не парит). bodyYOffset применяется к локальной Y кузова. + veh.bodyYOffset = -(bb.min.y - y) - veh.half.h; + } catch (e) { veh.bodyYOffset = -veh.half.h; } + inst.rootMesh.setParent(chassisNode); + inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0); + inst.rootMesh.rotation = Vector3.Zero(); + // Цвет кузова (tint поверх GLB-текстуры). + if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} } + } + } catch (e) { console.warn('[VehicleManager] body load failed', e); } + + // Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат + // колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1). + // Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно). + + // «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она + // висит/утоплена на стартовой y, пока никто не за рулём (нет tick). + this._settle(veh); + // Повторное оседание на следующих кадрах: физический грид статики может + // ещё не проиндексироваться к моменту спавна (await addInstance), тогда + // первый _settle не находит пол и машина зависает в воздухе (баг седана). + for (const d of [120, 350, 800]) { + setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d); + } + + return id; + } + + /** + * Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и + * роняем большим запасом (много шагов), чтобы гарантированно найти пол даже + * если стартовая y оказалась чуть ниже/выше или физика поздно готова. + */ + _settle(veh) { + try { + veh.pos.y += 0.5; + let landed = false; + for (let i = 0; i < 80; i++) { + const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0); + veh.pos.set(r.x, r.y, r.z); + if (r.hitY) { landed = true; break; } + } + if (landed) { + for (let i = 0; i < 4; i++) { + const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0); + veh.pos.set(r.x, r.y, r.z); + if (r.hitY) break; + } + } + veh.vy = 0; + veh.chassisNode.position.copyFrom(veh.pos); + veh.chassisNode.rotation.y = veh.yaw; + } catch (e) { /* ignore */ } + } + + getById(id) { return this.vehicles.get(id) || null; } + + /** Установить ввод водителя (из PlayerController). */ + setInput(veh, throttle, steer, handbrake) { + if (!veh) return; + veh.throttle = Math.max(-1, Math.min(1, throttle || 0)); + veh.steer = Math.max(-1, Math.min(1, steer || 0)); + veh.handbrake = !!handbrake; + } + + /** Физический шаг машины (вызывается каждый кадр пока есть водитель). */ + tickVehicle(veh, dt) { + if (!veh) return; + dt = Math.min(dt, 1 / 30); + const p = veh.params; + const prevSpeed = veh.speed; + + // Ускорение / торможение / реверс. + if (veh.throttle > 0) { + veh.speed += veh.throttle * p.enginePower * dt; + } else if (veh.throttle < 0) { + // S: сначала тормоз, потом задний ход (ограничен). + if (veh.speed > 0.2) veh.speed -= p.brake * dt; + else veh.speed += veh.throttle * p.enginePower * 0.5 * dt; + } + // Накат-трение. + veh.speed *= (1 - 1.2 * dt); + if (veh.handbrake) veh.speed *= (1 - 6 * dt); + // Клампы. + const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4; + if (veh.speed > maxFwd) veh.speed = maxFwd; + if (veh.speed < -maxRev) veh.speed = -maxRev; + if (Math.abs(veh.speed) < 0.05) veh.speed = 0; + + // Поворот (зависит от скорости — нельзя крутиться на месте). + const speedFrac = veh.speed / maxFwd; + veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt; + // Угол доворота передних колёс (визуал) — плавный lerp. + const targetSteer = veh.steer * 0.5; + veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8); + + // Направление и перемещение. + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const moveX = dir.x * veh.speed * dt; + const moveZ = dir.z * veh.speed * dt; + // Гравитация (машина сидит на полу/дороге). + veh.vy += -22 * dt; + + // Коллизия с миром через тот же солвер что у игрока. + let res; + try { + res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ); + } catch (e) { + res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false }; + } + veh.pos.set(res.x, res.y, res.z); + if (res.hitY) veh.vy = 0; + // Удар об стену — гасим ход. + if (res.hitX || res.hitZ) { + const force = Math.abs(veh.speed); + veh.speed *= 0.3; + for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} } + } + + // Применить к узлам. + veh.chassisNode.position.copyFrom(veh.pos); + veh.chassisNode.rotation.y = veh.yaw; + // Колёса: передние доворачивают, все катятся. + const roll = (veh.speed * dt) / 0.4; + for (const w of veh.wheels) { + if (w.isFront) w.node.rotation.y = veh.steerAngle; + w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2); + } + + if (Math.abs(veh.speed - prevSpeed) > 0.01) { + for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} } + } + // Падение в бездну — сигнал PlayerController высадить + респавн. + if (veh.pos.y < -25) return { fellOut: true }; + return null; + } + + /** Текущая скорость машины в м/с (для спидометра). */ + speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; } + + applyImpulse(veh, v) { + if (!veh || !v) return; + // Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению. + if (Number.isFinite(v.y)) veh.vy += Number(v.y); + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z; + veh.speed += horiz; + } + + dispose() { + for (const veh of this.vehicles.values()) { + try { + if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId); + for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId); + veh.chassisNode?.dispose?.(); + } catch (e) { /* ignore */ } + } + this.vehicles.clear(); + } +}