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 && (