Compare commits

...

2 Commits

Author SHA1 Message Date
min
dbdd61b4d6 feat(player): dev-skin через URL #skin= + убран beforeunload
All checks were successful
CI / Lint (pull_request) Successful in 55s
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 27s
CI / PR size check (pull_request) Successful in 10s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
LOCAL DEV: при запуске на localhost плеер берёт скин из:
1) hash-параметра #skin=<id> (сайт 3000 передаёт его при play-ticket
   через buildPlayerUrl(gameId, ticket, selectedSkin))
2) localStorage самого плеера (rublox_selected_skin)
3) дефолт skin_y-bot

Это нужно потому что:
- localStorage на :5173 (плеер) и :3000 (сайт) — РАЗНЫЕ хранилища
- В прод-БД пока legacy-скины (skin_sigma-labubu и др.), пока feature-flag
  RUBLOX_NEW_SKINS_AVAILABLE=false плеер не должен в неё лезть локально

PROD: только БД (rublox_equipped_skin) — поведение не меняется.

Также убран beforeunload-prompt: системное окно браузера невозможно
стилизовать (Chrome игнорирует кастомный текст с 2017), а уродливое
окно мешало. Случайное закрытие вкладки теперь без подтверждения.
2026-06-14 11:02:47 +03:00
min
8047cd366c Revert "Merge pull request 'revert: ����� Mixamo skin support' (#27) from revert/mixamo-skin-2026-06-13 into main"
This reverts commit 62ff0b01007684ed9ffdf6e550f7d257f58c3f9e, reversing
changes made to 830f4b8f4a2055e5e596a95b3268e30619c34724.
2026-06-14 11:02:25 +03:00
4 changed files with 99 additions and 44 deletions

View File

@ -209,9 +209,11 @@ const KubikonPlayer = () => {
const roomRef = useRef(null);
/** MultiplayerSync (мост между room и Babylon-сценой). */
const mpSyncRef = useRef(null);
/** Выбранный R15-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType. */
const skinFolderRef = useRef('skin_bacon-hair');
/** Выбранный Mixamo-скин текущего игрока (из rublox_equipped_skin).
* Грузится при старте, уходит в мультиплеер как modelType.
* 2026-06-13: дефолт сменён с skin_bacon-hair на skin_y-bot
* (Игрек-Бот, новый Mixamo-каталог). */
const skinFolderRef = useRef('skin_y-bot');
const [meta, setMeta] = useState(null); // { title, description, user_id, ... }
const [forbidden, setForbidden] = useState(false);
@ -309,36 +311,26 @@ const KubikonPlayer = () => {
return () => { active = false; };
}, [projectId]);
// Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W = закрыть вкладку,
// Ctrl+R = reload, Ctrl+T/N мешают). Большинство браузеров блокирует
// отмену системных шорткатов, но beforeunload даёт пользователю шанс
// подтвердить выход. Также превентим preventDefault на keydown для
// случаев когда фокус НЕ на window-уровне (Chrome иногда позволяет).
// Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W, Ctrl+R, Ctrl+T,
// Ctrl+N). Большинство браузеров блокирует отмену системных шорткатов,
// но мы пробуем preventDefault иногда срабатывает.
//
// 2026-06-14: beforeunload-подтверждение убрано по решению UX системное
// окно браузера невозможно стилизовать (с 2017 Chrome игнорирует кастомный
// текст), а уродливое модальное мешает. Случайное закрытие вкладки теперь
// просто закрывает игру без вопроса.
useEffect(() => {
const onKey = (e) => {
if (!e.ctrlKey && !e.metaKey) return;
// Список «опасных» в игре сочетаний превентим
const dangerousCodes = ['KeyW', 'KeyR', 'KeyT', 'KeyN'];
if (dangerousCodes.includes(e.code)) {
e.preventDefault();
e.stopPropagation();
}
};
const onBeforeUnload = (e) => {
// Если юзер сам нажал «Покинуть» в меню пропускаем без
// подтверждения. Флаг ставит exitPlayer().
if (window.__rubloxExplicitExit) return undefined;
// Случайное закрытие вкладки (Ctrl+W, X-кнопка) показываем
// подтверждение чтобы не потерять прогресс игры.
e.preventDefault();
e.returnValue = '';
return '';
};
window.addEventListener('keydown', onKey, { capture: true });
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('keydown', onKey, { capture: true });
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, []);
@ -589,14 +581,40 @@ const KubikonPlayer = () => {
// тогда player.setModelType подхватит правильный скин.
// Этот же skinFolder уйдёт в мультиплеер как modelType,
// чтобы соперники видели наш реальный скин.
let mySkin = 'skin_bacon-hair';
if (userId) {
//
// LOCAL DEV (localhost): берём скин из URL #skin=<id>
// (передаётся сайтом 3000 при нажатии «Играть»), потом из
// localStorage самого плеера, потом дефолт. В БД НЕ лезем
// прод-БД хранит легаси скины (skin_sigma-labubu и др.),
// которые мы не хотим грузить локально.
//
// PROD: только БД (rublox_equipped_skin).
let mySkin = 'skin_y-bot';
const isLocalDev = (typeof window !== 'undefined'
&& (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1'));
if (isLocalDev) {
try {
// 1) hash-параметр #skin=<id> (от сайта при play-ticket)
const m = window.location.hash.match(/[#&]skin=([\w-]+)/);
if (m && m[1]) {
mySkin = m[1];
console.log('[KubikonPlayer] local-dev skin (URL):', mySkin);
} else {
// 2) localStorage самого плеера
const localPick = localStorage.getItem('rublox_selected_skin');
if (localPick && typeof localPick === 'string') {
mySkin = localPick;
console.log('[KubikonPlayer] local-dev skin (LS):', mySkin);
}
}
} catch (e) {}
} else if (userId) {
try {
const skinRes = await Kubikon3DApi.getEquippedSkin(userId);
const sf = skinRes?.data?.skin_folder;
if (sf && typeof sf === 'string') mySkin = sf;
} catch (e) {
// Сеть/ошибка играем с дефолтным скином, не блокируем.
console.warn('[KubikonPlayer] equipped-skin load failed', e);
}
}
@ -831,7 +849,7 @@ const KubikonPlayer = () => {
// загружен при старте в skinFolderRef). Сервер всё равно перепроверит
// скин по userId из JWT и при расхождении возьмёт значение из БД
// так каждый игрок виден соперникам в своём реальном скине.
const modelType = skinFolderRef.current || 'skin_bacon-hair';
const modelType = skinFolderRef.current || 'skin_y-bot';
// Если у нас есть валидный reconnectionToken от прошлой сессии
// используем Colyseus reconnect (это та же сессия для сервера,
// allowReconnection(5) её подхватит, не будет +join/-leave цикла).

View File

@ -199,10 +199,9 @@ export class BabylonScene {
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
this._spawnPoint = { x: 0, y: 5, z: 0 };
// Модель персонажа для режима Play.
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
// 'character-*' — старые Kenney-модели.
this._playerModelType = 'skin_bacon-hair';
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot,
// нейтральный по полу). Старые скины (skin_bacon-hair и др.) удалены.
this._playerModelType = 'skin_y-bot';
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
this._worldHalf = 40;
@ -7523,7 +7522,9 @@ export class BabylonScene {
// форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt;
// character-a..g (Kenney) и legacy R15 (skin_bacon-hair и др.)
// мигрируем на новый дефолт skin_y-bot.
this._playerModelType = pmt.startsWith('character-') ? 'skin_y-bot' : pmt;
}
// Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') {

View File

@ -64,7 +64,7 @@ function loadSkinManifest() {
* @returns {Promise<{file:string, isR15:boolean, overrides:object}|null>}
*/
async function resolveRemoteModelSource(modelType) {
const typeId = modelType || 'skin_bacon-hair';
const typeId = modelType || 'skin_y-bot';
if (typeId.startsWith('skin_')) {
const manifest = await loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId);
@ -713,7 +713,7 @@ export class MultiplayerSync {
maxHp: player.maxHp ?? 100,
isDead: !!player.isDead,
username: player.username || sessionId,
modelType: player.modelType || 'skin_bacon-hair',
modelType: player.modelType || 'skin_y-bot',
animState: player.animState || 'idle',
// Если модель не успеет загрузиться, висит fallback-капсула.
fallbackMesh: null,

View File

@ -33,6 +33,29 @@ import { AccessoryManager } from './AccessoryManager';
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
/* Mixamo-скины (новые персонажи rublox-site /character-assets/skins/).
* 2026-06-11: эти 80 ID перенесены сюда из data/skinsCatalog.js фронта
* чтобы плеер их распознавал и грузил по правильному пути.
* Дефолтные: skin_x-bot (male), skin_y-bot (female/null). */
const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer', 'skin_erika-archer-bow',
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
'skin_maria-wprop', 'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);
const CAMERA_MODES = ['third', 'first', 'front'];
// Для режима 'sideview' (Кубикон Dash):
// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
@ -168,8 +191,8 @@ export class PlayerController {
this._stepUpDecay = 4.5;
// Модель игрока (грузится в start)
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
this._modelTypeId = 'skin_bacon-hair';
// 2026-06-13: дефолт сменён на skin_y-bot (Mixamo Y-Bot).
this._modelTypeId = 'skin_y-bot';
this._modelRoot = null;
this._modelMeshes = [];
// Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
@ -786,19 +809,32 @@ export class PlayerController {
return null;
}
if (typeId.startsWith('skin_')) {
// 2026-06-11: палитра скинов Рублокса заменена на 80 Mixamo.
// Mixamo-скины: /character-assets/skins/<id>.glb (на rublox-site).
// Legacy-скины (skin_bacon-hair / skin_sigma-labubu / skin_cop / ...)
// ещё могут приходить из БД пока feature-flag в storys выключен —
// их грузим из старого /kubikon-assets/characters/<id>/body.glb
// (R15-скелет). После заливки 80 GLB на rublox.pro и включения
// RUBLOX_NEW_SKINS_AVAILABLE=true legacy-ветка перестанет
// срабатывать (бэк начнёт отдавать только новые типы).
if (MIXAMO_SKINS.has(typeId)) {
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: `${base}/character-assets/skins/${typeId}.glb`,
isR15: false,
kind: 'non-humanoid-rigged', // Mixamo-rig, не R15
overrides: {},
isMixamo: true,
};
}
// Legacy R15-скин — через старый manifest.
const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId);
if (entry) {
// kind определяет систему анимации:
// 'r15' → R15-скелет (как раньше)
// 'non-humanoid-mesh' → single-mesh, процедурное покачивание
// 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
// Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
const kind = entry.kind || 'r15';
// absolute_file=true (источник /rublox/avatars) — file уже
// полный URL (legacy /kubikon-assets/... или дизайнерский
// /api-storys/...). Без флага — это легаси-формат
// skins_manifest.json без префикса.
const file = entry.absolute_file
? entry.file
: '/kubikon-assets/' + entry.file;
@ -812,7 +848,7 @@ export class PlayerController {
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
};
}
// нет в манифесте — пробуем прямой путь
// нет ни в Mixamo, ни в manifest — пробуем прямой legacy-путь
return {
file: `/kubikon-assets/characters/${typeId}/body.glb`,
isR15: true,