diff --git a/src/KubikonPlayer/GameMenu.jsx b/src/KubikonPlayer/GameMenu.jsx index b6e1119..5311836 100644 --- a/src/KubikonPlayer/GameMenu.jsx +++ b/src/KubikonPlayer/GameMenu.jsx @@ -63,14 +63,24 @@ export default function GameMenu({ }) { const [activeTab, setActiveTab] = useState('people'); - // ESC закрывает меню. Регистрируем в capture-фазе чтобы не конфликтовать - // с pointer-lock логикой KubikonPlayer. + // Закрытие меню: + // • НЕ fullscreen — Esc (классика) + // • Fullscreen — Tab (Esc отдаётся браузеру для выхода из FS) + // Регистрируем в capture-фазе чтобы не конфликтовать с pointer-lock. useEffect(() => { if (!visible) return; const onKey = (e) => { - if (e.key === 'Escape') { + const isFs = !!(typeof document !== 'undefined' && document.fullscreenElement); + if (e.key === 'Escape' && !isFs) { e.stopPropagation(); onClose(); + return; + } + if ((e.key === 'Tab' || e.code === 'Tab') && isFs) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + return; } // L/R hotkeys как в Godot if (e.key === 'l' || e.key === 'L') onExit?.(); @@ -218,9 +228,21 @@ function TabBar({ activeTab, onTab }) { } // ════════════════════════════════════════════════════════════════════ -// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc Продолжить +// BottomBar — 3 кнопки: L Покинуть / R Возродиться / Esc(Tab) Продолжить // ════════════════════════════════════════════════════════════════════ function BottomBar({ onExit, onRespawn, onResume }) { + // В fullscreen Esc отдан браузеру (выход из FS) — меню закрывается + // на Tab. В обычном режиме — Esc. + const [resumeKey, setResumeKey] = useState( + typeof document !== 'undefined' && document.fullscreenElement ? 'Tab' : 'Esc' + ); + useEffect(() => { + const onFsChange = () => { + setResumeKey(document.fullscreenElement ? 'Tab' : 'Esc'); + }; + document.addEventListener('fullscreenchange', onFsChange); + return () => document.removeEventListener('fullscreenchange', onFsChange); + }, []); return (
- +
); } @@ -565,19 +587,40 @@ function PlayerCard({ player, isMe, isFriend, isPending, onAddFriend }) { const username = String(player.username || '?'); const color = colorForUser(Number(player.user_id || 0), username); - // Аватар: 1) skin PNG (картинка персонажа — bacon/imposter/etc) — главный + // Аватар: 1) skin PNG (картинка персонажа) — главный // 2) photo_thumb_b64 (аватар майнкрафтии, fallback) // 3) photo URL (старое поле — fallback) // 4) буква-инициал // - // Скины лежат в /kubikon-assets/characters//avatar.png — это PNG - // персонажа в полный рост. Совпадает с Godot/exe-плеером. + // 2026-06-14: Mixamo-скины (skin_y-bot, skin_x-bot и т.д.) лежат на + // rublox-site в /character-assets/skins/.png. Legacy R15-скины + // (skin_bacon-hair, skin_sigma-labubu) — в /kubikon-assets/characters//avatar.png. + // Mixamo-набор детектим по тому что в slug нет дефиса с known-legacy-словами. + // Известные LEGACY R15-скины (бекон, импостер, сигма-лабубу и пр.): + // их PNG лежит в /kubikon-assets/characters//avatar.png. + // ВСЁ ОСТАЛЬНОЕ что начинается на 'skin_' — это Mixamo + // (с 2026-06-11 палитра заменена на 80 Mixamo-персонажей). + const LEGACY_SKINS = new Set([ + 'skin_bacon-hair', 'skin_sigma-labubu', 'skin_sparks-roblox', + 'skin_imposter', 'skin_cop', 'skin_baby', + 'skin_pizza', 'skin_burger', 'skin_taco', + ]); let avatarUrl = null; let isSkin = false; if (player.skin && typeof player.skin === 'string') { - // cache-bust обязателен: на 2026-05-27 фиксили 404 на этом пути, - // браузеры успели закэшировать негативный ответ - avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`; + const isLegacy = LEGACY_SKINS.has(player.skin); + if (!isLegacy && player.skin.startsWith('skin_')) { + // Mixamo: PNG на rublox-site (на проде rublox.pro, + // локально localhost:3000) рядом с GLB. + const base = (typeof window !== 'undefined' + && window.location.hostname === 'localhost') + ? 'http://localhost:3000' + : 'https://rublox.pro'; + avatarUrl = `${base}/character-assets/skins/${player.skin}.png`; + } else { + // Legacy R15: путь по старому шаблону. + avatarUrl = `/kubikon-assets/characters/${player.skin}/avatar.png?v=2026_05_27`; + } isSkin = true; } else if (player.photo_thumb_b64) { avatarUrl = player.photo_thumb_b64.startsWith('data:') diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index 6993de9..db0f6dd 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -223,9 +223,13 @@ const KubikonPlayer = () => { const [loadingScreenCfg, setLoadingScreenCfg] = useState(null); const [loadProgress, setLoadProgress] = useState(0); // Раньше была стартовая заглушка «тапни чтобы начать» — убрали по - // фидбэку, она бесила. Теперь fullscreen опционально через кнопку - // в углу. Этот state остался для совместимости с handleMobileStart. + // фидбэку, она бесила. Этот state остался для совместимости. const [mobileStartTapped, setMobileStartTapped] = useState(true); + // 2026-06-14: вернулся стартовый клик-экран — теперь нужен чтобы + // ВКЛЮЧИТЬ fullscreen и заблокировать Ctrl+W/Ctrl+T и др. системные + // хоткеи. Без этого браузер закрывает вкладку при случайном Ctrl+W. + // requestFullscreen() требует user gesture — поэтому без клика никак. + const [gameStarted, setGameStarted] = useState(false); const [hp, setHp] = useState({ hp: 100, maxHp: 100 }); // Скрипт через game.hud.setVisible(false) полностью скрывает стандартный HUD. const [stdHudVisible, setStdHudVisible] = useState(true); @@ -311,21 +315,54 @@ const KubikonPlayer = () => { return () => { active = false; }; }, [projectId]); - // Перехват Ctrl-комбинаций которые ломают игру (Ctrl+W, Ctrl+R, Ctrl+T, - // Ctrl+N). Большинство браузеров блокирует отмену системных шорткатов, - // но мы пробуем preventDefault — иногда срабатывает. + // Перехват системных Ctrl-комбинаций которые в WASD-игре регулярно + // нажимаются случайно и приводят к закрытию вкладки / открытию диалогов. + // В fullscreen Chrome даёт большинству этих хоткеев preventDefault'иться. // - // 2026-06-14: beforeunload-подтверждение убрано по решению UX — системное - // окно браузера невозможно стилизовать (с 2017 Chrome игнорирует кастомный - // текст), а уродливое модальное мешает. Случайное закрытие вкладки теперь - // просто закрывает игру без вопроса. + // 2026-06-14: добавлены KeyD (закладка), KeyS (сохранить страницу), + // KeyA (выделить всё), KeyP (печать), KeyU (исходник), KeyJ/KeyH (история). + // Все буквы которые могут зажиматься с Ctrl во время WASD-управления. 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(); + // 1. Системные F-клавиши и навигация в любой момент: + // F5 = reload, F11 = fullscreen toggle (нужен Esc-way), + // Backspace = browser back (если фокус не на input), + // Tab — мешает фокусом UI. + // F11 ОСТАВЛЯЕМ — даёт юзеру способ выйти из fullscreen. + if (e.code === 'F5' || e.code === 'F3' || e.code === 'F6' + || e.code === 'F7') { + e.preventDefault(); e.stopPropagation(); return; + } + // 2. Ctrl/Cmd-комбинации. WASD-клавиши ОБРАБАТЫВАЕМ отдельно: + // блокируем системное действие браузера (preventDefault), но + // НЕ stopPropagation — иначе PlayerController не увидит ввод. + // Игрок часто приседает (Ctrl) и одновременно идёт (W/A/S/D). + if (e.ctrlKey || e.metaKey) { + const wasd = ['KeyW', 'KeyA', 'KeyS', 'KeyD']; + if (wasd.includes(e.code)) { + e.preventDefault(); // блокируем Ctrl+W (закрытие), Ctrl+D (закладка) и т.д. + return; // НЕ stopPropagation — пусть PlayerController увидит + } + const blocked = [ + 'KeyR', // reload + 'KeyT', // new tab + 'KeyN', // new window + 'KeyP', // print + 'KeyU', // view source + 'KeyJ', // downloads + 'KeyH', // history + 'KeyF', // find on page + 'KeyG', // find next + 'KeyL', // focus address bar + 'KeyO', // open file + 'Tab', // switch tab + 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', + 'Digit6', 'Digit7', 'Digit8', 'Digit9', + ]; + if (blocked.includes(e.code)) { + e.preventDefault(); + e.stopPropagation(); + } } }; window.addEventListener('keydown', onKey, { capture: true }); @@ -593,31 +630,45 @@ const KubikonPlayer = () => { const isLocalDev = (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')); - if (isLocalDev) { - try { - // 1) hash-параметр #skin= (от сайта при 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) { + // Источник скина по приоритету: + // 1) hash-параметр #skin= в URL (передаёт сайт при play-ticket; + // работает и на localhost и на проде) + // 2) БД через /equipped-skin (если есть userId) + // 3) localStorage самого плеера (fallback на localhost для отладки) + // 4) skin_y-bot (дефолт) + try { + console.log('[KubikonPlayer] hash=', window.location.hash, + '| LS rublox_selected_skin=', (typeof localStorage !== 'undefined' ? localStorage.getItem('rublox_selected_skin') : '?')); + const m = window.location.hash.match(/[#&]skin=([\w-]+)/); + if (m && m[1]) { + mySkin = m[1]; + console.log('[KubikonPlayer] skin from URL:', mySkin); + } + } catch (e) {} + if (mySkin === 'skin_y-bot' && userId) { + // 2) Лезем в БД (через прод-API). Бэк отдаёт либо + // выбранный валидный скин, либо дефолт по полу. try { const skinRes = await Kubikon3DApi.getEquippedSkin(userId); const sf = skinRes?.data?.skin_folder; - if (sf && typeof sf === 'string') mySkin = sf; + if (sf && typeof sf === 'string') { + mySkin = sf; + console.log('[KubikonPlayer] skin from DB:', mySkin); + } } catch (e) { console.warn('[KubikonPlayer] equipped-skin load failed', e); } } + if (mySkin === 'skin_y-bot' && isLocalDev) { + // 3) Локальный fallback на localStorage плеера. + try { + const localPick = localStorage.getItem('rublox_selected_skin'); + if (localPick && typeof localPick === 'string') { + mySkin = localPick; + console.log('[KubikonPlayer] skin from local LS:', mySkin); + } + } catch (e) {} + } skinFolderRef.current = mySkin; try { scene.setPlayerModelType?.(mySkin); } catch (e) {} @@ -1068,6 +1119,20 @@ const KubikonPlayer = () => { setMobileStartTapped(true); }, []); + /** Стартовый клик «Начать игру» — запрашивает fullscreen + * (Chrome блокирует Ctrl+W/Ctrl+T в fullscreen) и снимает оверлей. */ + const handleGameStart = useCallback(async () => { + const root = document.documentElement; + const req = root.requestFullscreen + || root.webkitRequestFullscreen + || root.mozRequestFullScreen + || root.msRequestFullscreen; + if (req) { + try { await req.call(root); } catch (e) { /* юзер запретил — играем без FS */ } + } + setGameStarted(true); + }, []); + // При выходе со страницы — снимаем fullscreen / orientation lock, // чтобы возврат в школу не остался залочен в landscape. useEffect(() => { @@ -1177,6 +1242,69 @@ const KubikonPlayer = () => { /> )} + {/* 2026-06-14: стартовый оверлей. Один клик → fullscreen → + * Chrome блокирует Ctrl+W/Ctrl+T/Ctrl+R и др. Без него + * юзер случайно закрывает вкладку, теряет прогресс. */} + {!loading && !gameStarted && ( +
+
+ Нажми чтобы играть +
+
+ Игра откроется в полноэкранном режиме — + это защитит от случайного закрытия вкладки + (Ctrl+W, Ctrl+T и др.). +
+ Выход: Esc или F11. +
+ +
+ )} + {/* Игровой UI (HUD, GUI, hotbar, прицел в первом лице) */} {!loading && ( <> @@ -1415,11 +1543,14 @@ const KubikonPlayer = () => { )} )} - {/* Кнопка «полный экран» — маленькая, в правом верхнем углу, - только на тач-устройствах. Браузеры требуют user gesture - для requestFullscreen() — поэтому без кнопки никак. - Кнопка автоматически скрывается после входа в fullscreen. */} - {isTouch && !loading && !document.fullscreenElement && ( + {/* Кнопка «полный экран» — маленькая, в правом верхнем углу. + Показывается на ВСЕХ устройствах (desktop + touch): + - touch — нужна чтобы скрыть UI браузера на телефоне + - desktop — в fullscreen Chrome блокирует Ctrl+W/Ctrl+T + и прочие системные хоткеи, которые иначе закрывают вкладку. + Браузеры требуют user gesture для requestFullscreen() — + поэтому без кнопки никак. */} + {!loading && !document.fullscreenElement && (