Compare commits

...

12 Commits

Author SHA1 Message Date
147a7588d9 Merge branch 'main' into user7278-loadingscreen
All checks were successful
CI / Lint (pull_request) Successful in 1m16s
CI / Build (pull_request) Successful in 1m39s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-03 07:35:34 +00:00
min
24b6360266 feat(14): Vehicle System V1+V2 � ���� � ����� (#19)
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m36s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m25s
2026-06-02 23:37:41 +00:00
min
eb6430182b feat(14): Vehicle System V1+V2 — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 57s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
Фича-парность со студией (задача 14):
- VehicleManager + VehicleHud (спидометр-стрелка) идентичны студийным.
- game.scene.spawn('vehicle:car'), onVehicleEnter/Exit, hold-F/E, камера follow/V.
- Звук мотора (рокот+LFO), оседание машины на землю (_settle+повторы),
  скрытие водителя, респавн при падении, shadow-caster фильтр (фикс FPS).
- incrementPlay(id, userId) — передаём user_id для cooldown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 02:25:15 +03:00
min
4ca8cdd9bd Merge pull request 'feat(13): ������� ���� ���� (game.mainMenu)' (#18) from feat/main-menu-task13 into main
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m35s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m9s
2026-06-02 21:21:53 +00:00
min
b2cff903ba feat(13): главное меню игры (game.mainMenu) — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 59s
CI / Build (pull_request) Successful in 1m37s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Фича-парность со студией: namespace game.mainMenu (show/hide/setCamera/
setPatchNotes/колбэки) + зацикливание облёта через onCutsceneDone +
game.player.setInputBlocked в worker + handler в runtime + passthrough
scene.mainMenu в load. Проверено: меню работает в плеере на игре 2434.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 00:07:47 +03:00
min
dd7688c4d7 Merge pull request 'feat(12): ������������� Loading Screen (game.loading)' (#17) from feat/loading-screen-task12 into main
All checks were successful
CI / Lint (push) Successful in 58s
CI / Build (push) Successful in 1m37s
CI / Secret scan (push) Successful in 2m29s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m9s
2026-06-02 20:10:51 +00:00
min
302db5e1f4 feat(12): внутриигровой Loading Screen (game.loading) — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 58s
CI / Build (pull_request) Successful in 1m36s
CI / Secret scan (pull_request) Successful in 2m40s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Фича-парность со студией: LoadingScreenOverlay.js (DOM-оверлей),
namespace game.loading в worker (хэндл local→real + колбэки через
globalEvent), cmd loading.* + _ensureLoadingScreen в GameRuntime,
class-ref + tick + load конфига в BabylonScene. Проверено: экран
загрузки работает в плеере на тест-игре «Такси-босс» 2427.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:00:42 +03:00
min
f420501481 ci: redeploy player на S1 (фикс прав www-data→min на build/)
All checks were successful
CI / Lint (push) Successful in 55s
CI / Build (push) Successful in 1m32s
CI / Secret scan (push) Successful in 2m52s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:03:47 +03:00
min
9e3bc60a76 ci: verify-шаг не валится на недоступном root-мусоре в build/wiki
All checks were successful
CI / Lint (push) Successful in 55s
CI / Build (push) Successful in 1m33s
CI / Secret scan (push) Successful in 2m29s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m52s
du -sh натыкался на systemd-private-* в build/wiki/tmp (Permission
denied) → exit 1 → deploy failure, хотя rsync долетел. Теперь verify
проверяет наличие index.html, а du неблокирующий (2>/dev/null||true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:43:12 +03:00
min
61ac40ab61 Merge pull request 'feat(11): placement mode � ����������� ��������� (tycoon)' (#15) from feat/placement-task11 into main
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m33s
CI / Secret scan (push) Successful in 2m27s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m55s
2026-06-02 17:24:22 +00:00
min
91af8514c5 fix(11): порт game.format в worker плеера (money/number/time)
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m29s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Скрипт «Мой завод» (id 2345) падал в плеере на game.format.money —
неймспейс был только в worker студии. Из-за краха в синхронной части
не доходило до inventoryUi.create/placement → инвентарь не показывался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:09:27 +03:00
min
517545b0cf feat(11): порт placement mode в плеер (фича-парность со студией)
Some checks failed
CI / Lint (pull_request) Successful in 54s
CI / Build (pull_request) Successful in 1m30s
CI / Secret scan (pull_request) Failing after 12s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
PlacementManager + ShopInventoryUi + проводка game.placement.*/inventoryUi.*
в worker/GameRuntime/BabylonScene — опубликованные tycoon-игры с расстановкой
теперь работают в плеере. + TerrainManager backFaceCulling=false (воксели не
просвечивают), cleanup usermodel при Stop, Hotbar скрыт при пустом инвентаре.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:06:51 +03:00
15 changed files with 21257 additions and 18940 deletions

View File

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

View File

@ -596,8 +596,10 @@ const KubikonPlayer = () => {
try { scene.setPlayerModelType?.(mySkin); } catch (e) {} try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoading(false); setLoading(false);
// Засчитываем плей // Засчитываем плей. Передаём user_id (если залогинен)
Kubikon3DApi.incrementPlay(projectId).catch(() => {}); // это активирует self-cooldown (автор не накручивает себе)
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
// Запускаем игру сразу // Запускаем игру сразу
setTimeout(() => { setTimeout(() => {
scene.enterPlayMode?.(); scene.enterPlayMode?.();

View File

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

View File

@ -16,6 +16,13 @@ import Icon from './Icon';
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
if (!visible) return null; if (!visible) return null;
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
// одного предмета панель инвентаря НЕ показываем вовсе. Пустой hotbar
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
// Панель появится автоматически, как только в слот попадёт предмет.
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
if (!hasAnyItem) return null;
const SLOT_COUNT = 5; const SLOT_COUNT = 5;
const cells = []; const cells = [];
for (let i = 0; i < SLOT_COUNT; i++) { for (let i = 0; i < SLOT_COUNT; i++) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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 спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
let _spinCssInjected = false;
function injectSpinnerCss() {
if (_spinCssInjected) return;
_spinCssInjected = true;
try {
const style = document.createElement('style');
style.id = 'kbn-loading-spin-css';
style.textContent =
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
'@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; }
}
}

View File

@ -314,9 +314,10 @@ export class ModelManager {
r.getChildMeshes(false).forEach(m => { r.getChildMeshes(false).forEach(m => {
m.isPickable = true; m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId }; m.metadata = { isModel: true, instanceId: this._nextInstanceId };
// Тени: GLB-модель и принимает тени, и отбрасывает их // Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
// (через addShadowCaster в refreshAllShadows). if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
m.receiveShadows = true; m.receiveShadows = true;
}
clonedMeshes.push(m); clonedMeshes.push(m);
}); });
// И сам root тоже на всякий // И сам root тоже на всякий

View File

@ -0,0 +1,586 @@
/**
* PlacementManager drag-and-drop размещение объектов в 3D-мире (задача 11).
*
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре
* полупрозрачный preview летает за курсором ЛКМ ставит, ПКМ/Esc отменяет».
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
*
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
*
* Скриптовый API игры (через GameRuntime game.placement.*):
* start(itemKey, opts) войти в режим расстановки
* cancel() выйти (как ПКМ/Esc)
* confirm() поставить на текущей позиции (как ЛКМ)
* rotate(deg) повернуть preview (как R / колесо)
* onPlace / onCancel / onMove колбэки (роутятся в worker как события)
*
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
*/
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
export class PlacementManager {
constructor(scene3d) {
this.s = scene3d; // BabylonScene
this.scene = scene3d.scene;
this._active = null; // активная сессия placement или null
this._tickObs = null; // observer renderLoop
this._placementSeq = 0;
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
this._onPlace = null;
this._onCancel = null;
this._onMove = null;
}
setCallbacks({ onPlace, onCancel, onMove } = {}) {
if (onPlace !== undefined) this._onPlace = onPlace;
if (onCancel !== undefined) this._onCancel = onCancel;
if (onMove !== undefined) this._onMove = onMove;
}
isActive() { return !!this._active; }
/**
* Войти в placement-режим.
* @param {string} itemKey ключ предмета (передаётся обратно в onPlace)
* @param {object} opts см. 11_placement_mode.md §2.1
* @returns {string} placementId
*/
start(itemKey, opts = {}) {
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
if (this._active) this._teardown(false);
const o = {
previewType: opts.previewType || 'primitive:cube',
previewColor: opts.previewColor || '#a0522d',
previewScale: Number(opts.previewScale) || 1,
// modelScale — реальный scale воксельной модели для превью (чтобы
// полупрозрачная копия была того же размера, что и ставимый объект).
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
forbidOverlap: opts.forbidOverlap !== false,
grid: opts.grid != null ? Number(opts.grid) : 1,
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
showZoneOutline: opts.showZoneOutline !== false,
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
cost: Number(opts.cost) || 0,
currency: opts.currency || 'rubles',
hint: opts.hint || '',
hintError: opts.hintError || 'Разместите в отмеченном месте!',
placedType: opts.placedType || null,
chainPlace: !!opts.chainPlace,
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
forceCameraMode: opts.forceCameraMode !== false,
freezePlayer: !!opts.freezePlayer,
previewPulse: opts.previewPulse !== false,
};
const id = 'placement_' + (++this._placementSeq);
const preview = this._createPreview(o);
this._active = {
id, itemKey, opts: o, preview,
rotationY: 0,
valid: false,
pos: new Vector3(0, 0, 0),
zoneOutline: null,
arrowFxRef: null,
placedCount: 0,
pulseT: 0,
prevCameraMode: null,
prevFrozen: null,
};
// Зона размещения — красный контур по AABB.
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
if (o.showArrowFrom && o.targetZone) this._createArrow();
// Камера: placement требует видимый курсор — в first переводим в third.
if (o.forceCameraMode) this._forceThirdCamera();
// Заморозка игрока (опция).
if (o.freezePlayer) this._setPlayerFrozen(true);
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
this._emitHud(true);
this._startTick();
return id;
}
cancel() {
if (!this._active) return;
const cb = this._onCancel;
this._teardown(true);
if (typeof cb === 'function') cb();
}
/** Поставить на текущей позиции (как ЛКМ). */
confirm() {
const a = this._active;
if (!a) return false;
if (!a.valid) {
// Невалидно — звук «не получилось» + мигание preview в красный.
this._playFail();
this._flashInvalid();
return false;
}
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
if (ox || oz) {
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
const rx = ox * c - oz * s;
const rz = ox * s + oz * c;
ox = rx; oz = rz;
}
const result = {
itemKey: a.itemKey,
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
rotationY: a.rotationY,
};
// Списание стоимости (если задана и есть валюта-хелпер в движке).
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
a.placedCount++;
this._playPlace();
if (typeof this._onPlace === 'function') this._onPlace(result);
if (a.opts.chainPlace) {
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
// Просто продолжаем тик; valid пересчитается в следующем кадре.
return true;
}
this._teardown(false);
return true;
}
/** Повернуть preview на N градусов вокруг Y. */
rotate(deg) {
const a = this._active;
if (!a) return;
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
if (a.preview) a.preview.rotation.y = a.rotationY;
}
// ── Внутреннее ──────────────────────────────────────────────────────
_createPreview(o) {
const base = Color3.FromHexString(o.previewColor || '#a0522d');
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
// root, а не центрируется — куб-превью раньше центрировался → предмет
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
const pt = o.previewType || '';
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
stub.isPickable = false;
stub._baseColor = base;
this._buildUserModelPreview(pt, o, base);
return stub;
}
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
const edge = Number(o.previewScale) || 1;
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
const mat = new StandardMaterial('placementGhostMat', this.scene);
mat.diffuseColor = base;
mat.emissiveColor = base.scale(0.25);
mat.specularColor = new Color3(0, 0, 0);
mat.alpha = o.ghostOpacity;
mat.disableLighting = true;
ghost.material = mat;
ghost.isPickable = false;
ghost._baseColor = base;
return ghost;
}
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
async _buildUserModelPreview(previewType, o, base) {
try {
const um = this.s.userModelManager;
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
scale: o.modelScale || o.previewScale || 1,
canCollide: false, visible: true, anchored: true,
currentUserId: this.s._currentUserId || null,
});
if (instId == null) return;
// Сессия уже могла завершиться/смениться, пока грузилось.
const a = this._active;
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
const inst = um.instances.get(instId);
if (!inst || !inst.rootNode) return;
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
ghostMat.diffuseColor = base;
ghostMat.emissiveColor = base.scale(0.25);
ghostMat.specularColor = new Color3(0, 0, 0);
ghostMat.alpha = o.ghostOpacity;
ghostMat.disableLighting = true;
ghostMat.backFaceCulling = false;
for (const m of (inst.meshes || [])) {
m.isPickable = false;
m.material = ghostMat;
}
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const m of (inst.meshes || [])) {
m.computeWorldMatrix(true);
const bb = m.getBoundingInfo().boundingBox;
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
}
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
a._modelOffsetX = offX;
a._modelOffsetZ = offZ;
// Удаляем временный stub, новый root становится превью.
const old = a.preview;
a.preview = inst.rootNode;
a.preview._baseColor = base;
a.preview._userModelInstId = instId; // для teardown
a.preview._ghostMat = ghostMat;
if (old) { try { old.dispose(); } catch (e) {} }
} catch (e) {
// тихо — превью некритично, останется stub
}
}
_startTick() {
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
}
_tick() {
const a = this._active;
if (!a) return;
const scn = this.scene;
// Raycast от камеры через текущую позицию курсора.
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
if (pick && pick.hit && pick.pickedPoint) {
let p = pick.pickedPoint.clone();
// surfaceMode 'ground' — нормаль должна смотреть вверх.
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
let surfOk = true;
if (a.opts.surfaceMode === 'ground') {
const n = pick.getNormal(true);
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
}
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
if (a.opts.grid > 0) {
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
}
a.pos.copyFrom(p);
if (a.preview) {
if (a.preview._userModelInstId != null) {
// userModel-превью: root = угол модели. Вычитаем offset центра
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
// Высота p.y без сдвига (низ модели на поверхность).
a.preview.position.set(
p.x - (a._modelOffsetX || 0),
p.y,
p.z - (a._modelOffsetZ || 0),
);
} else {
// Куб-превью центрирован → поднимаем на полвысоты.
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
}
}
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
a.valid = surfOk
&& this._inZone(p, a.opts)
&& this._distanceOk(p, a.opts)
&& this._limitOk(a.opts)
&& this._affordable(a)
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
} else {
a.valid = false;
}
// Цвет preview: зелёный/красный.
this._applyTint(a, a.valid);
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (a.opts.previewPulse && pmat) {
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
}
// HUD-индикатор ошибки (красный текст когда невалидно).
this._emitHudError(!a.valid);
// Стрелка к зоне — обновим конечную точку (если игрок движется).
if (a.arrowFxRef) this._updateArrow();
// onMove колбэк автору (каждый кадр).
if (typeof this._onMove === 'function') {
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
}
}
_applyTint(a, valid) {
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
if (!pmat) return;
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
return; // во время flash держим красный
}
const tint = valid ? VALID_TINT : INVALID_TINT;
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
pmat.diffuseColor = new Color3(
b.r * tint.r + tint.r * 0.4,
b.g * tint.g + tint.g * 0.4,
b.b * tint.b + tint.b * 0.4,
);
pmat.emissiveColor = tint.scale(0.35);
}
_flashInvalid() {
const a = this._active;
if (!a || !a.preview || !a.preview.material) return;
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
a.preview.material.diffuseColor = INVALID_TINT;
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
}
_isSurface(mesh, o) {
if (!o.allowSurfaces) return true; // любая поверхность
// Совпадение по имени или тегу.
const name = mesh.name || '';
if (o.allowSurfaces.some(s => name.includes(s))) return true;
const tags = mesh.metadata && mesh.metadata.tags;
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
return false;
}
_inZone(p, o) {
if (!o.targetZone) return true;
const z = this._resolveZoneMesh(o.targetZone);
if (!z) return true;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
}
_distanceOk(p, o) {
if (!o.maxDistance || o.maxDistance <= 0) return true;
const pl = this.s.player && this.s.player._pos;
if (!pl) return true;
const dx = p.x - pl.x, dz = p.z - pl.z;
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
}
_limitOk(o) {
if (!o.maxItems || o.maxItems <= 0) return true;
return (this._active.placedCount || 0) < o.maxItems;
}
_overlapsSide(p, a) {
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
const newY = p.y; // высота поверхности (низ нового объекта)
const newTop = newY + (a.opts.previewScale || 1);
for (const m of this.scene.meshes) {
if (!m.isPickable || m === a.preview) continue;
if (!m.getBoundingInfo) continue;
const bb = m.getBoundingInfo().boundingBox;
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
const c = bb.centerWorld;
const dx = c.x - p.x, dz = c.z - p.z;
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
if (overlapY) return true;
}
return false;
}
/** Хватает ли валюты на текущий предмет (если задан баланс). */
_affordable(a) {
const cur = a.opts.currency;
const cost = a.opts.cost || 0;
if (!cost) return true;
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
return cost <= bal;
}
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
setBalance(currency, amount) {
if (!this._balances) this._balances = {};
if (currency) this._balances[currency] = Number(amount) || 0;
}
_resolveZoneMesh(ref) {
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
if (ref && ref.getBoundingInfo) return ref;
if (typeof ref === 'string') {
// через scene3d — найти примитив/модель по ref
try {
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
if (mesh) return mesh;
} catch { /* ignore */ }
// fallback — по имени
return this.scene.getMeshByName(ref) || null;
}
return null;
}
_createZoneOutline() {
const a = this._active;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const bb = z.getBoundingInfo().boundingBox;
const min = bb.minimumWorld, max = bb.maximumWorld;
const y = min.y + 0.06;
const pts = [
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
new Vector3(min.x, y, min.z),
];
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
line.color = new Color3(1, 0.19, 0.19);
line.isPickable = false;
// glow-имитация: чуть приподнятая полупрозрачная плоскость
a.zoneOutline = line;
}
_createArrow() {
const a = this._active;
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
try {
const bm = this.s.beamManager;
if (!bm || !bm.addPointer) return;
const z = this._resolveZoneMesh(a.opts.targetZone);
if (!z) return;
const c = z.getBoundingInfo().boundingBox.centerWorld;
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
? this.s.player._pos
: this._resolveZoneMesh(a.opts.showArrowFrom);
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
if (!fromV) return;
a.arrowFxRef = bm.addPointer({
from: { x: fromV.x, y: fromV.y, z: fromV.z },
to: { x: c.x, y: c.y + 0.6, z: c.z },
preset: 'guide',
});
} catch { /* стрелка не критична */ }
}
_updateArrow() {
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
}
_forceThirdCamera() {
const a = this._active;
try {
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
a.prevCameraMode = this.s.player.getCameraMode();
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
}
} catch { /* ignore */ }
}
_setPlayerFrozen(frozen) {
try {
if (this.s.player && this.s.player.setFrozen) {
if (this._active) this._active.prevFrozen = true;
this.s.player.setFrozen(frozen);
}
} catch { /* ignore */ }
}
_spendCurrency(currency, amount) {
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
try {
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
} catch { /* ignore */ }
}
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
_emitHud(show) {
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
try {
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
} catch { /* ignore */ }
}
_emitHudError(isError) {
try {
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
} catch { /* ignore */ }
}
_teardown(emitHudOff) {
const a = this._active;
if (!a) return;
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
if (a.preview) {
try {
if (a.preview._userModelInstId != null && this.s.userModelManager) {
// userModel-превью — это реальный инстанс; удаляем через менеджер
// (снимет из Map + dispose мешей). + чистим ghost-материал.
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
} else {
a.preview.material && a.preview.material.dispose();
a.preview.dispose();
}
} catch { /* ignore */ }
}
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
}
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
}
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
}
this._active = null;
if (emitHudOff !== false) this._emitHud(false);
}
/** Полный сброс при Stop игры. */
dispose() {
this._teardown(true);
this._onPlace = this._onCancel = this._onMove = null;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
/**
* ShopInventoryUi готовый GUI-кит «слот-инвентарь магазина» (задача 11).
*
* Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover.
* Клик по слоту колбэк onSlotClick(item) обычно автор вызывает внутри
* game.placement.start(...). Слот серый и некликабельный, если валюты мало
* (показывается, когда заданы showCurrency + текущий баланс через setBalance).
*
* Реализация лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с
* иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к
* родителю canvas, абсолютным позиционированием.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI).
const SLOT_ICONS = {
crate: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
};
function iconSvg(name) {
return SLOT_ICONS[name] || SLOT_ICONS.box;
}
export class ShopInventoryUi {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this.items = [];
this.balance = {}; // currency → amount
this.currency = '';
this.showCost = true;
this._onSlotClick = null;
this._slotEls = [];
}
create(opts, onSlotClick) {
this.remove();
this.items = Array.isArray(opts.items) ? opts.items : [];
this.currency = opts.showCurrency || '';
this.showCost = opts.showCost !== false;
this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null;
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
// Контейнер должен быть position:relative чтобы absolute-панель легла поверх.
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const pos = opts.position || 'bottom';
const slotSize = Number(opts.slotSize) || 80;
const spacing = Number(opts.spacing) || 4;
const root = document.createElement('div');
root.className = 'kbn-shop-inv';
const sideStyle = {
bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`,
top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`,
left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
}[pos] || '';
root.style.cssText =
`position:absolute;display:flex;gap:${spacing}px;z-index:40;` +
`padding:8px;border-radius:14px;` +
`background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` +
`box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle;
this.items.forEach((it, idx) => {
const slot = document.createElement('button');
slot.type = 'button';
slot.dataset.key = it.key;
slot.style.cssText =
`width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` +
`display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` +
`cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` +
`background:linear-gradient(180deg,#3a4a66,#26324a);` +
`transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`;
slot.innerHTML =
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
(this.showCost && it.cost
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
: '');
slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } };
slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; };
slot.onclick = () => {
if (slot.disabled) return;
if (this._onSlotClick) this._onSlotClick(it);
};
this._slotEls[idx] = slot;
root.appendChild(slot);
});
parent.appendChild(root);
this.root = root;
this._refreshAffordability();
}
_curShort() {
const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' };
return map[this.currency] || this.currency;
}
/** Обновить баланс валюты — слоты дороже баланса станут серыми. */
setBalance(currency, amount) {
if (currency) this.balance[currency] = Number(amount) || 0;
this._refreshAffordability();
}
_refreshAffordability() {
if (!this.currency) return; // без валюты все слоты активны
const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity;
this.items.forEach((it, idx) => {
const slot = this._slotEls[idx];
if (!slot) return;
const afford = (Number(it.cost) || 0) <= bal;
slot.disabled = !afford;
slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)';
slot.style.cursor = afford ? 'pointer' : 'not-allowed';
slot.style.opacity = afford ? '1' : '0.7';
});
}
remove() {
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
this._slotEls = [];
}
dispose() { this.remove(); this._onSlotClick = null; }
}

View File

@ -514,6 +514,10 @@ export class TerrainManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
// 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
// backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
// по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
// Ambient ставим в белый, чтобы hemisphere-light освещал материал // Ambient ставим в белый, чтобы hemisphere-light освещал материал
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что // с любой стороны (иначе нижние/тыловые грани выходят серыми, что
// особенно заметно на светло-бежевом песке — он становится серым). // особенно заметно на светло-бежевом песке — он становится серым).
@ -543,6 +547,12 @@ export class TerrainManager {
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = true; mat.useAlphaFromDiffuseTexture = true;
mat.alpha = def.alpha; mat.alpha = def.alpha;
} else {
// RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
// воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
mat.diffuseTexture.hasAlpha = false;
mat.useAlphaFromDiffuseTexture = false;
mat.transparencyMode = 0;
} }
if (Array.isArray(def.emissive)) { if (Array.isArray(def.emissive)) {
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);

95
src/engine/VehicleHud.js Normal file
View File

@ -0,0 +1,95 @@
/**
* VehicleHud HUD водителя (задача 14): круглый спидометр со стрелкой,
* передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как
* ShopInventoryUi). Показывается пока игрок за рулём.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
export class VehicleHud {
constructor(scene3d) {
this.s = scene3d;
this.root = null;
this.needle = null;
this.speedText = null;
this.gearText = null;
this._maxKmh = 80;
}
show(maxKmh) {
this.remove();
this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10);
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
const root = document.createElement('div');
root.className = 'kbn-veh-hud';
root.style.cssText =
'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' +
'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;';
// SVG-циферблат.
const R = 70, CX = 80, CY = 80;
const startA = 135, endA = 405; // дуга 270°
const ticks = [];
const N = 8;
for (let i = 0; i <= N; i++) {
const a = (startA + (endA - startA) * i / N) * Math.PI / 180;
const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4);
const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14);
ticks.push(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8d0dc" stroke-width="2"/>`);
const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4;
const val = Math.round(this._maxKmh * i / N);
ticks.push(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#9aa6b8" font-size="9" text-anchor="middle">${val}</text>`);
}
root.innerHTML =
`<svg viewBox="0 0 160 160" width="160" height="160">` +
`<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(16,20,32,0.82)" stroke="#3a4760" stroke-width="3"/>` +
ticks.join('') +
`<line id="kbn-veh-needle" x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - R + 18}" stroke="#ff5a3c" stroke-width="3.5" stroke-linecap="round" transform="rotate(-135 ${CX} ${CY})"/>` +
`<circle cx="${CX}" cy="${CY}" r="6" fill="#ff5a3c"/>` +
`<text id="kbn-veh-speed" x="${CX}" y="${CY + 30}" fill="#ffe44a" font-size="22" font-weight="800" text-anchor="middle">0</text>` +
`<text x="${CX}" y="${CY + 44}" fill="#9aa6b8" font-size="9" text-anchor="middle">км/ч</text>` +
`<text id="kbn-veh-gear" x="${CX}" y="${CY - 16}" fill="#7fe0a0" font-size="18" font-weight="900" text-anchor="middle">N</text>` +
`</svg>`;
parent.appendChild(root);
this.root = root;
this.needle = root.querySelector('#kbn-veh-needle');
this.speedText = root.querySelector('#kbn-veh-speed');
this.gearText = root.querySelector('#kbn-veh-gear');
this._CX = CX; this._CY = CY;
// Подсказки клавиш справа снизу.
const keys = document.createElement('div');
keys.className = 'kbn-veh-keys';
keys.style.cssText =
'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' +
'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' +
'text-shadow:0 1px 3px rgba(0,0,0,0.7);';
keys.innerHTML = '<div><b>WASD</b> — руль</div><div><b>V</b> — камера</div><div><b>E</b> — выйти</div>';
parent.appendChild(keys);
this._keys = keys;
}
/** Обновить стрелку/число/передачу. speed — м/с (signed). */
update(speedMs) {
if (!this.needle) return;
const kmh = Math.abs(speedMs) * 3.6;
const frac = Math.max(0, Math.min(1, kmh / this._maxKmh));
const ang = -135 + 270 * frac; // -135°..+135°
this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`);
if (this.speedText) this.speedText.textContent = String(Math.round(kmh));
if (this.gearText) {
const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D');
this.gearText.textContent = g;
this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0');
}
}
remove() {
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; }
this.needle = this.speedText = this.gearText = null;
}
dispose() { this.remove(); }
}

View File

@ -0,0 +1,249 @@
import { Vector3, TransformNode } from '@babylonjs/core';
/**
* VehicleManager система транспорта (задача 14, фаза V1 аркадная + V2 параметры).
*
* Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) +
* 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ:
* speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer
* (масштаб от скорости нет вращения на месте); коллизия с миром через
* physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с
* другими машинами НЕ сталкиваются (V1) только chassis с миром.
*
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
*/
const DEFAULT_PARAMS = {
mass: 1200,
enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с.
maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров
turnSpeed: 1.8, // рад/с при полной скорости
brake: 26, // замедление при тормозе/реверсе
drive: 'rwd',
};
export class VehicleManager {
constructor(scene3d) {
this.s = scene3d;
this.scene = scene3d.scene;
this.vehicles = new Map(); // id → veh
this._seq = 0;
}
get _physics() { return this.s.physics; }
get _models() { return this.s.modelManager; }
/**
* Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }.
* Возвращает Promise<id>.
*/
async spawn(opts) {
opts = opts || {};
const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0;
// Идемпотентность: если машина с такой позицией уже есть — не плодим
// (защита от двойного выполнения скрипта спавна → дубли машин).
for (const v of this.vehicles.values()) {
if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id;
}
const id = ++this._seq;
const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0;
const yaw = Number(opts.rotationY) || 0;
const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) };
const modelType = opts.model || 'car-sedan';
// chassis-узел — родитель кузова и колёс.
const chassisNode = new TransformNode(`vehicle_${id}`, this.scene);
chassisNode.position = new Vector3(x, y, z);
chassisNode.rotation = new Vector3(0, yaw, 0);
const veh = {
id, name: opts.name || 'Машина', params,
spawnX: x, spawnZ: z, // для дедупа повторного спавна
chassisNode, bodyInstanceId: null, wheels: [],
pos: new Vector3(x, y, z), yaw, vy: 0,
speed: 0, steerAngle: 0,
half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова
throttle: 0, steer: 0, handbrake: false,
driver: null,
handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] },
ref: opts.ref || null,
};
this.vehicles.set(id, veh);
// Кузов (GLB Kenney car-kit).
try {
const bodyId = await this._models.addInstance(modelType, x, y, z, yaw);
veh.bodyInstanceId = bodyId;
const inst = this._models.instances.get(bodyId);
if (inst && inst.rootMesh) {
// Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
// (в мировых координатах, кузов ещё в (x,y,z)).
try {
const bb = inst.rootMesh.getHierarchyBoundingVectors(true);
veh.half = {
w: Math.max(0.6, (bb.max.x - bb.min.x) / 2),
h: Math.max(0.4, (bb.max.y - bb.min.y) / 2),
d: Math.max(1.0, (bb.max.z - bb.min.z) / 2),
};
// Насколько низ кузова ниже точки спавна y — чтобы посадить
// кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле,
// не парит). bodyYOffset применяется к локальной Y кузова.
veh.bodyYOffset = -(bb.min.y - y) - veh.half.h;
} catch (e) { veh.bodyYOffset = -veh.half.h; }
inst.rootMesh.setParent(chassisNode);
inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0);
inst.rootMesh.rotation = Vector3.Zero();
// Цвет кузова (tint поверх GLB-текстуры).
if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} }
}
} catch (e) { console.warn('[VehicleManager] body load failed', e); }
// Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат
// колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1).
// Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно).
// «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она
// висит/утоплена на стартовой y, пока никто не за рулём (нет tick).
this._settle(veh);
// Повторное оседание на следующих кадрах: физический грид статики может
// ещё не проиндексироваться к моменту спавна (await addInstance), тогда
// первый _settle не находит пол и машина зависает в воздухе (баг седана).
for (const d of [120, 350, 800]) {
setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d);
}
return id;
}
/**
* Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и
* роняем большим запасом (много шагов), чтобы гарантированно найти пол даже
* если стартовая y оказалась чуть ниже/выше или физика поздно готова.
*/
_settle(veh) {
try {
veh.pos.y += 0.5;
let landed = false;
for (let i = 0; i < 80; i++) {
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0);
veh.pos.set(r.x, r.y, r.z);
if (r.hitY) { landed = true; break; }
}
if (landed) {
for (let i = 0; i < 4; i++) {
const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0);
veh.pos.set(r.x, r.y, r.z);
if (r.hitY) break;
}
}
veh.vy = 0;
veh.chassisNode.position.copyFrom(veh.pos);
veh.chassisNode.rotation.y = veh.yaw;
} catch (e) { /* ignore */ }
}
getById(id) { return this.vehicles.get(id) || null; }
/** Установить ввод водителя (из PlayerController). */
setInput(veh, throttle, steer, handbrake) {
if (!veh) return;
veh.throttle = Math.max(-1, Math.min(1, throttle || 0));
veh.steer = Math.max(-1, Math.min(1, steer || 0));
veh.handbrake = !!handbrake;
}
/** Физический шаг машины (вызывается каждый кадр пока есть водитель). */
tickVehicle(veh, dt) {
if (!veh) return;
dt = Math.min(dt, 1 / 30);
const p = veh.params;
const prevSpeed = veh.speed;
// Ускорение / торможение / реверс.
if (veh.throttle > 0) {
veh.speed += veh.throttle * p.enginePower * dt;
} else if (veh.throttle < 0) {
// S: сначала тормоз, потом задний ход (ограничен).
if (veh.speed > 0.2) veh.speed -= p.brake * dt;
else veh.speed += veh.throttle * p.enginePower * 0.5 * dt;
}
// Накат-трение.
veh.speed *= (1 - 1.2 * dt);
if (veh.handbrake) veh.speed *= (1 - 6 * dt);
// Клампы.
const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4;
if (veh.speed > maxFwd) veh.speed = maxFwd;
if (veh.speed < -maxRev) veh.speed = -maxRev;
if (Math.abs(veh.speed) < 0.05) veh.speed = 0;
// Поворот (зависит от скорости — нельзя крутиться на месте).
const speedFrac = veh.speed / maxFwd;
veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt;
// Угол доворота передних колёс (визуал) — плавный lerp.
const targetSteer = veh.steer * 0.5;
veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8);
// Направление и перемещение.
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const moveX = dir.x * veh.speed * dt;
const moveZ = dir.z * veh.speed * dt;
// Гравитация (машина сидит на полу/дороге).
veh.vy += -22 * dt;
// Коллизия с миром через тот же солвер что у игрока.
let res;
try {
res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ);
} catch (e) {
res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false };
}
veh.pos.set(res.x, res.y, res.z);
if (res.hitY) veh.vy = 0;
// Удар об стену — гасим ход.
if (res.hitX || res.hitZ) {
const force = Math.abs(veh.speed);
veh.speed *= 0.3;
for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} }
}
// Применить к узлам.
veh.chassisNode.position.copyFrom(veh.pos);
veh.chassisNode.rotation.y = veh.yaw;
// Колёса: передние доворачивают, все катятся.
const roll = (veh.speed * dt) / 0.4;
for (const w of veh.wheels) {
if (w.isFront) w.node.rotation.y = veh.steerAngle;
w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2);
}
if (Math.abs(veh.speed - prevSpeed) > 0.01) {
for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} }
}
// Падение в бездну — сигнал PlayerController высадить + респавн.
if (veh.pos.y < -25) return { fellOut: true };
return null;
}
/** Текущая скорость машины в м/с (для спидометра). */
speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; }
applyImpulse(veh, v) {
if (!veh || !v) return;
// Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению.
if (Number.isFinite(v.y)) veh.vy += Number(v.y);
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z;
veh.speed += horiz;
}
dispose() {
for (const veh of this.vehicles.values()) {
try {
if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId);
for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId);
veh.chassisNode?.dispose?.();
} catch (e) { /* ignore */ }
}
this.vehicles.clear();
}
}