Compare commits
2 Commits
4ca8cdd9bd
...
24b6360266
| Author | SHA1 | Date | |
|---|---|---|---|
| 24b6360266 | |||
|
|
eb6430182b |
@ -593,8 +593,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?.();
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -67,6 +67,8 @@ import { BeamManager } from './BeamManager';
|
|||||||
import { PlacementManager } from './PlacementManager';
|
import { PlacementManager } from './PlacementManager';
|
||||||
import { ShopInventoryUi } from './ShopInventoryUi';
|
import { ShopInventoryUi } from './ShopInventoryUi';
|
||||||
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
|
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
|
||||||
|
import { VehicleManager } from './VehicleManager';
|
||||||
|
import { VehicleHud } from './VehicleHud';
|
||||||
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
|
||||||
import { DynamicsManager } from './DynamicsManager';
|
import { DynamicsManager } from './DynamicsManager';
|
||||||
import { Environment } from './Environment';
|
import { Environment } from './Environment';
|
||||||
@ -150,6 +152,9 @@ export class BabylonScene {
|
|||||||
// Placement mode (задача 11) — фича-парность со студией.
|
// Placement mode (задача 11) — фича-парность со студией.
|
||||||
this.placementManager = null;
|
this.placementManager = null;
|
||||||
this.shopInventoryUi = null;
|
this.shopInventoryUi = null;
|
||||||
|
this.vehicleManager = null; // задача 14
|
||||||
|
this.vehicleHud = null;
|
||||||
|
this._VehicleHudClass = VehicleHud;
|
||||||
this._PlacementManagerClass = PlacementManager;
|
this._PlacementManagerClass = PlacementManager;
|
||||||
this._ShopInventoryUiClass = ShopInventoryUi;
|
this._ShopInventoryUiClass = ShopInventoryUi;
|
||||||
// Экран загрузки (задача 12).
|
// Экран загрузки (задача 12).
|
||||||
@ -1304,6 +1309,7 @@ export class BabylonScene {
|
|||||||
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
// Voxel-террейн тоже участвует в физике. У террейна свой размер
|
||||||
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
|
||||||
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
|
||||||
|
this.vehicleManager = new VehicleManager(this); // задача 14
|
||||||
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
|
||||||
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
|
||||||
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
// voxelWorld), что позволяет постепенно мигрировать без поломки.
|
||||||
@ -1893,6 +1899,20 @@ export class BabylonScene {
|
|||||||
if (typeof mesh.getBoundingInfo !== 'function') return;
|
if (typeof mesh.getBoundingInfo !== 'function') return;
|
||||||
if (typeof mesh.getTotalVertices !== 'function') return;
|
if (typeof mesh.getTotalVertices !== 'function') return;
|
||||||
if (mesh.getTotalVertices() <= 0) return;
|
if (mesh.getTotalVertices() <= 0) return;
|
||||||
|
// ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
|
||||||
|
// пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
|
||||||
|
// (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
|
||||||
|
try {
|
||||||
|
const bb = mesh.getBoundingInfo().boundingBox;
|
||||||
|
const ext = bb.extendSizeWorld || bb.extendSize;
|
||||||
|
if (ext) {
|
||||||
|
const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
|
||||||
|
const maxDim = Math.max(w, h, d);
|
||||||
|
const minDim = Math.min(w, h, d);
|
||||||
|
if (maxDim < 1.6 || minDim < 0.35) return;
|
||||||
|
if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7561,6 +7581,8 @@ export class BabylonScene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
|
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
|
||||||
|
if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
|
||||||
|
if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
|
||||||
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
|
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
|
||||||
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
|
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
|
||||||
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }
|
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }
|
||||||
|
|||||||
@ -492,6 +492,12 @@ export class GameRuntime {
|
|||||||
this._objectData = {};
|
this._objectData = {};
|
||||||
this._interactables = [];
|
this._interactables = [];
|
||||||
this._activeInteractRef = null;
|
this._activeInteractRef = null;
|
||||||
|
// Задача 14: убрать машины и HUD водителя, чтобы при повторном start
|
||||||
|
// не плодились дубликаты (в плеере start может вызываться повторно).
|
||||||
|
try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {}
|
||||||
|
try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {}
|
||||||
|
this._vehHudShown = false;
|
||||||
|
try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {}
|
||||||
this._watchedTouchRefs = null;
|
this._watchedTouchRefs = null;
|
||||||
this._watchedClickRefs = null;
|
this._watchedClickRefs = null;
|
||||||
this._roomState = {};
|
this._roomState = {};
|
||||||
@ -615,6 +621,9 @@ export class GameRuntime {
|
|||||||
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
|
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
|
||||||
if (this._interactables.length > 0) this._updateInteractables();
|
if (this._interactables.length > 0) this._updateInteractables();
|
||||||
|
|
||||||
|
// Задача 14: HUD водителя.
|
||||||
|
this._updateVehicleHud();
|
||||||
|
|
||||||
// Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
|
// Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
|
||||||
const hp = this.scene3d?.player?.hp ?? 100;
|
const hp = this.scene3d?.player?.hp ?? 100;
|
||||||
const aliveNow = hp > 0;
|
const aliveNow = hp > 0;
|
||||||
@ -784,7 +793,34 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Резолв позиции интерактивного объекта (по ref). */
|
/** Резолв позиции интерактивного объекта (по ref). */
|
||||||
|
_updateVehicleHud() {
|
||||||
|
const player = this.scene3d?.player;
|
||||||
|
const veh = player?._inVehicle;
|
||||||
|
if (veh) {
|
||||||
|
const hud = this._ensureVehicleHud();
|
||||||
|
if (hud) {
|
||||||
|
if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; }
|
||||||
|
try { hud.update(veh.speed); } catch (e) {}
|
||||||
|
}
|
||||||
|
} else if (this._vehHudShown) {
|
||||||
|
this._vehHudShown = false;
|
||||||
|
try { this.scene3d?.vehicleHud?.remove(); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureVehicleHud() {
|
||||||
|
if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud;
|
||||||
|
if (!this.scene3d || !this.scene3d._VehicleHudClass) return null;
|
||||||
|
try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); }
|
||||||
|
catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); }
|
||||||
|
return this.scene3d.vehicleHud || null;
|
||||||
|
}
|
||||||
|
|
||||||
_resolveInteractPos(it) {
|
_resolveInteractPos(it) {
|
||||||
|
if (it.ref && it.ref.indexOf('vehicle:') === 0) {
|
||||||
|
const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8)));
|
||||||
|
return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null;
|
||||||
|
}
|
||||||
const tgt = this._resolveTweenTarget(it.ref);
|
const tgt = this._resolveTweenTarget(it.ref);
|
||||||
if (tgt) {
|
if (tgt) {
|
||||||
const d = tgt.data;
|
const d = tgt.data;
|
||||||
@ -802,8 +838,26 @@ export class GameRuntime {
|
|||||||
if (!this._activeInteractRef) return;
|
if (!this._activeInteractRef) return;
|
||||||
const it = this._interactables.find(x => x.ref === this._activeInteractRef);
|
const it = this._interactables.find(x => x.ref === this._activeInteractRef);
|
||||||
if (!it || it.key !== String(key).toLowerCase()) return;
|
if (!it || it.key !== String(key).toLowerCase()) return;
|
||||||
// событие 'interact' скрипту с target = этим объектом
|
this._fireInteract(it);
|
||||||
this.routeEvent(it.target, 'interact', {});
|
}
|
||||||
|
|
||||||
|
_fireInteract(it) {
|
||||||
|
if (!it) return;
|
||||||
|
if (it.isInst) {
|
||||||
|
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref });
|
||||||
|
} else if (it.target) {
|
||||||
|
this.routeEvent(it.target, 'interact', {});
|
||||||
|
}
|
||||||
|
if (it.ref && it.ref.indexOf('vehicle:') === 0) {
|
||||||
|
const vid = Number(it.ref.slice(8));
|
||||||
|
const veh = this.scene3d?.vehicleManager?.getById?.(vid);
|
||||||
|
const player = this.scene3d?.player;
|
||||||
|
if (veh && player && !player._inVehicle) {
|
||||||
|
player.enterVehicle(veh);
|
||||||
|
player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); };
|
||||||
|
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Прокрутка всех активных твинов на dt секунд. */
|
/** Прокрутка всех активных твинов на dt секунд. */
|
||||||
@ -2591,6 +2645,8 @@ export class GameRuntime {
|
|||||||
text: payload.text || 'Взаимодействовать',
|
text: payload.text || 'Взаимодействовать',
|
||||||
distance: Number(payload.distance) || 4,
|
distance: Number(payload.distance) || 4,
|
||||||
key: payload.key || 'e',
|
key: payload.key || 'e',
|
||||||
|
holdDuration: Number(payload.holdDuration) || 0,
|
||||||
|
isInst: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -2598,6 +2654,22 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cmd === 'inst.registerInteract') {
|
||||||
|
try {
|
||||||
|
const ref = payload?.ref;
|
||||||
|
if (ref && !this._interactables.some(it => it.ref === ref)) {
|
||||||
|
this._interactables.push({
|
||||||
|
ref, target: null,
|
||||||
|
text: payload.text || 'Взаимодействовать',
|
||||||
|
distance: Number(payload.distance) || 4,
|
||||||
|
key: payload.key || 'e',
|
||||||
|
holdDuration: Number(payload.holdDuration) || 0,
|
||||||
|
isInst: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cmd === 'scene.setLabel') {
|
if (cmd === 'scene.setLabel') {
|
||||||
try {
|
try {
|
||||||
const ref = payload?.ref;
|
const ref = payload?.ref;
|
||||||
@ -3392,6 +3464,31 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
this.scheduleSceneSnapshot();
|
this.scheduleSceneSnapshot();
|
||||||
}
|
}
|
||||||
|
} else if (kind === 'vehicle') {
|
||||||
|
const opts = payload;
|
||||||
|
const p = this.scene3d?.vehicleManager?.spawn({
|
||||||
|
model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
|
||||||
|
params: opts.params, x: opts.x, y: opts.y, z: opts.z,
|
||||||
|
rotationY: opts.rotationY || 0, ref,
|
||||||
|
});
|
||||||
|
Promise.resolve(p).then((vid) => {
|
||||||
|
if (vid == null) return;
|
||||||
|
const realRef = 'vehicle:' + vid;
|
||||||
|
this._localToReal.set(ref, realRef);
|
||||||
|
this._notifySpawnResolved(ref, realRef);
|
||||||
|
const veh = this.scene3d?.vehicleManager?.getById?.(vid);
|
||||||
|
if (veh && !this._interactables.some(it => it.ref === realRef)) {
|
||||||
|
this._interactables.push({
|
||||||
|
ref: realRef, target: null,
|
||||||
|
text: 'Enter', objectName: veh.name,
|
||||||
|
distance: Math.max(4, veh.half.d + 2), key: 'f',
|
||||||
|
holdDuration: 0.4, isInst: true, isVehicle: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.scheduleSceneSnapshot();
|
||||||
|
}).catch((err) => {
|
||||||
|
this._log('error', 'spawn vehicle failed: ' + (err?.message || err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._log('error', 'scene.spawn failed: ' + (e?.message || e));
|
this._log('error', 'scene.spawn failed: ' + (e?.message || e));
|
||||||
|
|||||||
@ -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 тоже на всякий
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -79,6 +79,8 @@ let _playerJoinHandlers = [];
|
|||||||
let _playerLeaveHandlers = [];
|
let _playerLeaveHandlers = [];
|
||||||
// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7).
|
// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7).
|
||||||
let _cutsceneDoneHandlers = [];
|
let _cutsceneDoneHandlers = [];
|
||||||
|
let _vehicleEnterHandlers = []; // задача 14
|
||||||
|
let _vehicleExitHandlers = [];
|
||||||
let _mpMessageHandlers = {}; // name → [fn]
|
let _mpMessageHandlers = {}; // name → [fn]
|
||||||
// Подписки game.room.onChange(key, fn): key → [fn].
|
// Подписки game.room.onChange(key, fn): key → [fn].
|
||||||
let _roomChangeHandlers = {};
|
let _roomChangeHandlers = {};
|
||||||
@ -102,7 +104,7 @@ let _selfInteractHandlers = [];
|
|||||||
const _instTouchHandlers = new Map();
|
const _instTouchHandlers = new Map();
|
||||||
function _instHandlerBucket(ref) {
|
function _instHandlerBucket(ref) {
|
||||||
let b = _instTouchHandlers.get(ref);
|
let b = _instTouchHandlers.get(ref);
|
||||||
if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); }
|
if (!b) { b = { touch: [], untouch: [], click: [], interact: [] }; _instTouchHandlers.set(ref, b); }
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
|
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
|
||||||
@ -426,6 +428,17 @@ function _getOrCreateInstance(ref, kindHint) {
|
|||||||
_instHandlerBucket(ref).click.push(fn);
|
_instHandlerBucket(ref).click.push(fn);
|
||||||
_send('inst.watchClick', { ref });
|
_send('inst.watchClick', { ref });
|
||||||
};
|
};
|
||||||
|
if (prop === 'onInteract') return (fn, opts) => {
|
||||||
|
if (typeof fn !== 'function') return;
|
||||||
|
_instHandlerBucket(ref).interact.push(fn);
|
||||||
|
_send('inst.registerInteract', {
|
||||||
|
ref,
|
||||||
|
text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать',
|
||||||
|
distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4,
|
||||||
|
key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e',
|
||||||
|
holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
@ -647,6 +660,7 @@ function _buildSelfApi() {
|
|||||||
text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать',
|
text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать',
|
||||||
distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4,
|
distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4,
|
||||||
key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e',
|
key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e',
|
||||||
|
holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
move(x, y, z) {
|
move(x, y, z) {
|
||||||
@ -1285,6 +1299,8 @@ const game = {
|
|||||||
onCutsceneDone(fn) {
|
onCutsceneDone(fn) {
|
||||||
if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn);
|
if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn);
|
||||||
},
|
},
|
||||||
|
onVehicleEnter(fn) { if (typeof fn === 'function') _vehicleEnterHandlers.push(fn); },
|
||||||
|
onVehicleExit(fn) { if (typeof fn === 'function') _vehicleExitHandlers.push(fn); },
|
||||||
/** Игрок покинул комнату. fn({sessionId, name}). */
|
/** Игрок покинул комнату. fn({sessionId, name}). */
|
||||||
onPlayerLeave(fn) {
|
onPlayerLeave(fn) {
|
||||||
if (typeof fn === 'function') _playerLeaveHandlers.push(fn);
|
if (typeof fn === 'function') _playerLeaveHandlers.push(fn);
|
||||||
@ -1469,6 +1485,17 @@ const game = {
|
|||||||
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
|
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
if (kind === 'vehicle') {
|
||||||
|
_localRefSeq++;
|
||||||
|
const ref = 'vehicle:_local_' + _localRefSeq;
|
||||||
|
_send('scene.spawn', {
|
||||||
|
kind: 'vehicle', subType,
|
||||||
|
model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
|
||||||
|
params: opts.params || {},
|
||||||
|
x, y, z, rotationY: opts.rotationY || 0, ref,
|
||||||
|
});
|
||||||
|
return _getOrCreateInstance(ref, 'vehicle') || ref;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
/** Удалить объект по ref. */
|
/** Удалить объект по ref. */
|
||||||
@ -3794,6 +3821,9 @@ self.onmessage = (e) => {
|
|||||||
const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click;
|
const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click;
|
||||||
for (const fn of list) _safeCall(fn, payload, 'inst.' + t);
|
for (const fn of list) _safeCall(fn, payload, 'inst.' + t);
|
||||||
}
|
}
|
||||||
|
} else if (t === 'instInteract') {
|
||||||
|
const b = _instTouchHandlers.get(payload && payload.ref);
|
||||||
|
if (b) for (const fn of b.interact) _safeCall(fn, payload, 'inst.onInteract');
|
||||||
} else if (t === 'hpChange') {
|
} else if (t === 'hpChange') {
|
||||||
for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange');
|
for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange');
|
||||||
} else if (t === 'mobKilled') {
|
} else if (t === 'mobKilled') {
|
||||||
@ -3825,6 +3855,12 @@ self.onmessage = (e) => {
|
|||||||
} else if (t === 'cutsceneDone') {
|
} else if (t === 'cutsceneDone') {
|
||||||
// Катсцена камеры завершилась (Фаза 5.7).
|
// Катсцена камеры завершилась (Фаза 5.7).
|
||||||
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
|
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
|
||||||
|
} else if (t === 'vehicleEnter') {
|
||||||
|
const vref = 'vehicle:' + (payload && payload.vehicleId);
|
||||||
|
for (const fn of _vehicleEnterHandlers) _safeCall(fn, vref, 'onVehicleEnter');
|
||||||
|
} else if (t === 'vehicleExit') {
|
||||||
|
const vref = 'vehicle:' + (payload && payload.vehicleId);
|
||||||
|
for (const fn of _vehicleExitHandlers) _safeCall(fn, vref, 'onVehicleExit');
|
||||||
} else if (t === 'playerJoin') {
|
} else if (t === 'playerJoin') {
|
||||||
// payload: { sessionId, name }
|
// payload: { sessionId, name }
|
||||||
for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin');
|
for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin');
|
||||||
|
|||||||
95
src/engine/VehicleHud.js
Normal file
95
src/engine/VehicleHud.js
Normal 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(); }
|
||||||
|
}
|
||||||
249
src/engine/VehicleManager.js
Normal file
249
src/engine/VehicleManager.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user