From 2afd6a287aed42eb6369959713b155d2441147da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 08:27:59 +0300 Subject: [PATCH 01/13] =?UTF-8?q?fix(player):=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=203D-=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=20+=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B2=20Roblox=20(=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=2001,=2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 01 — billboard-клик не работал: _handlePlayClick не проверял кнопки табличек. Добавил pick по billboard-мешу из центра экрана → pickButtonAt → fireClick (BabylonScene._handlePlayClick). Задача 02 — управление было старым (всегда pointer-lock, ПКМ не работал): - start(): lock только в perma-режимах (first/lockfirst/sideview/shift-lock), в third курсор виден свободно - onCanvasClick: не лочит в third (курсор для GUI/3D-табличек) - ПКМ-orbit: зажал ПКМ в third → lock+вращение, отпустил → курсор вернулся - onWheel: авто-переход third↔first при зуме (порог 0.7), экспоненциальный шаг - onPointerLockChange: отпускание ПКМ в third НЕ выходит из Play (раньше выходило) - _applyCursorVisibility / _isPermaLockMode хелперы Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine/BabylonScene.js | 23 ++++++++ src/engine/PlayerController.js | 103 +++++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 80c98f3..4855a81 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -2934,6 +2934,29 @@ export class BabylonScene { } if (!this.gameRuntime) return; + + // === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) === + // Пикаем из центра экрана (как _pickFromCenter — в Play обычно + // pointer-lock). Если попали в кнопку таблички → fireClick и выходим. + if (this.billboardUiManager && this.primitiveManager) { + const w = this.engine?.getRenderWidth?.() || this.canvas.width; + const h = this.engine?.getRenderHeight?.() || this.canvas.height; + const bpick = this.scene.pick(w / 2, h / 2, (m) => + m && m.metadata && m.metadata.primitiveId != null + && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'); + if (bpick && bpick.hit && bpick.pickedMesh) { + const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId); + const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null; + if (bdata && uv) { + const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y); + if (buttonId) { + this.billboardUiManager.fireClick(bdata, buttonId); + return; // клик по табличке обработан + } + } + } + } + const pick = this._pickFromCenter(); const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null; const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null; diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index c3ff5b5..bd966e9 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -150,6 +150,9 @@ export class PlayerController { this._lockFirstPerson = false; // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере. this._shiftLock = false; + // Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора. + this._rmbHeld = false; + this._mouseIconVisible = true; // Ввод this._codes = new Set(); @@ -395,10 +398,28 @@ export class PlayerController { this._beforeRender = () => this._tick(); this.scene.registerBeforeRender(this._beforeRender); - // Сразу запросить Pointer Lock. Promise-форма (новые Chrome) может - // отклониться с SecurityError если предыдущий lock ещё не отпущен — - // в этом случае ждём отпускания и пробуем снова. - this._requestPointerLockSafe(); + // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock + // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно — + // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ. + if (this._isPermaLockMode()) { + this._requestPointerLockSafe(); + } + this._applyCursorVisibility(); + } + + /** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */ + _isPermaLockMode() { + return this._cameraMode === 'first' || this._cameraMode === 'lockfirst' + || this._cameraMode === 'sideview' || this._shiftLock; + } + + /** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в + * first/lock — скрыт. Учитывает game.input.setMouseIconVisible. */ + _applyCursorVisibility() { + if (!this.canvas) return; + const locked = (document.pointerLockElement === this.canvas); + const show = (this._mouseIconVisible !== false) && !locked; + try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ } } /** @@ -2191,10 +2212,13 @@ export class PlayerController { _setupInput() { const canvas = this.canvas; + // Задача 02: ЛКМ-клик НЕ берёт pointer-lock в third (курсор свободен для + // GUI/3D-табличек). Lock берётся только в perma-режимах (first/lock) — + // но там он уже взят в start(). В third lock включает только зажатая ПКМ. const onCanvasClick = () => { - // В UI-режиме клик по канвасу НЕ перехватывает мышь if (this._uiCursorMode) return; - if (this._active && document.pointerLockElement !== canvas) { + if (this._active && this._isPermaLockMode() + && document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); @@ -2203,6 +2227,32 @@ export class PlayerController { }; canvas.addEventListener('click', onCanvasClick); + // Задача 02: ПКМ-orbit — зажал ПКМ в third → lock + камера крутится, + // отпустил → курсор вернулся. В perma-режимах ПКМ не нужен. + const onRmbDown = (e) => { + if (e.button !== 2 || this._uiCursorMode) return; + if (!this._isPermaLockMode() && document.pointerLockElement !== canvas) { + this._rmbHeld = true; + try { + const p = canvas.requestPointerLock?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (err) { /* ignore */ } + } + }; + const onRmbUp = (e) => { + if (e.button !== 2) return; + if (this._rmbHeld) { + this._rmbHeld = false; + if (!this._isPermaLockMode() && document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) { /* ignore */ } + } + } + }; + const onCtxMenu = (e) => { if (!this._uiCursorMode) e.preventDefault(); }; + canvas.addEventListener('mousedown', onRmbDown); + document.addEventListener('mouseup', onRmbUp); + canvas.addEventListener('contextmenu', onCtxMenu); + // === UI-режим: mousedown / mouseup → callback (для drag-игр) === const onCanvasMouseDown = (e) => { if (!this._uiCursorMode) return; @@ -2254,15 +2304,38 @@ export class PlayerController { }; document.addEventListener('mousemove', onMouseMove); - // Колесо в 3rd-person — меняет дистанцию + // Задача 02: колесо = зум third-камеры с авто-переходом third↔first. const onWheel = (e) => { if (!this._active) return; - // Задача 04: модал с freezeCamera — колесо не зумит. - if (this._cameraFrozen) { e.preventDefault(); return; } - if (this._cameraMode !== 'third') return; - this._thirdDistance += Math.sign(e.deltaY) * 0.5; + if (this._cameraFrozen) { e.preventDefault(); return; } // модал + if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD + // В first зум наружу возвращает в third (если не lockfirst). + if (this._cameraMode === 'first') { + if (e.deltaY > 0 && !this._lockFirstPerson) { + this._cameraMode = 'third'; + this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5; + if (!this._isPermaLockMode() && document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) { /* ignore */ } + } + this._applyCursorVisibility(); + this._applyCameraMode?.(); + } + e.preventDefault(); + return; + } + if (this._cameraMode !== 'third') { e.preventDefault(); return; } + // Экспоненциальный шаг (плавнее вблизи). + this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15); if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN; if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; + // Зум внутрь до порога → авто-переход в first (Roblox-style). + const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7; + if (this._thirdDistance <= THRESH) { + this._cameraMode = 'first'; + this._requestPointerLockSafe(); + this._applyCursorVisibility(); + this._applyCameraMode?.(); + } e.preventDefault(); }; canvas.addEventListener('wheel', onWheel, { passive: false }); @@ -2270,11 +2343,17 @@ export class PlayerController { let wasLocked = false; const onPointerLockChange = () => { const locked = document.pointerLockElement === canvas; + this._applyCursorVisibility(); // задача 02: вернуть/скрыть курсор if (locked) { wasLocked = true; } else if (wasLocked && this._active) { - // Если мы САМИ переключились в UI-cursor mode — не выходим из Play + wasLocked = false; if (this._uiCursorMode) return; + // Задача 02: в third потеря lock = отпустили ПКМ (orbit) ИЛИ + // вышли зумом из first — это НЕ выход из Play. Выход (Esc) только + // из perma-режимов с постоянным lock (first/lockfirst/shift-lock). + if (this._rmbHeld) { this._rmbHeld = false; return; } + if (this._cameraMode === 'third' || this._cameraMode === 'front') return; if (this._onExitRequest) this._onExitRequest(); } }; -- 2.47.2 From 85f8198c7c0befa24dda918ba66784bd2f458de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 09:17:19 +0300 Subject: [PATCH 02/13] =?UTF-8?q?fix(player):=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=BD=D0=B0=20+=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=2002=20+=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Стартовый скин не ставился: блок чтения scene.skins/playerModelType стоял НИЖЕ предзагрузки модели и enterPlayMode. Перенёс ВЫШЕ — теперь PlayerController при старте видит корректный _playerModelType. 2. Меню открывалось каждые ~4с: onPointerLockChange звал _onExitRequest при любой потере lock. В third/front потеря lock = отпустили ПКМ (orbit), это НЕ выход. Меню (Esc) только из perma-режимов (first/lockfirst/sideview/ shift-lock). 3. Управление 02: start() лочит только в perma-режимах; onCanvasClick не лочит в third (курсор свободен для GUI/3D-табличек); ПКМ-orbit (onRmbDown/Up); onWheel авто-переход third<->first; _isPermaLockMode/_applyCursorVisibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine/BabylonScene.js | 60 +++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 4855a81..0d3b9a1 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -7154,6 +7154,35 @@ export class BabylonScene { this._syncUserModelColliders(); } + // === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера === + // ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе + // PlayerController прочитает старый _playerModelType (баг: пончик 2046 + // не ставился — skins.default применялся ниже, после предзагрузки). + // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g'); + // форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем. + if (state.scene.playerModelType) { + const pmt = state.scene.playerModelType; + this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : pmt; + } + // Задача 07: конфиг скинов { default, unlocked, shopVisible, coins, customGlbs }. + if (state.scene.skins && typeof state.scene.skins === 'object') { + this._skinsConfig = { + default: state.scene.skins.default || null, + unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [], + shopVisible: state.scene.skins.shopVisible !== false, + coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0, + customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [], + }; + // Стартовый скин из skins.default имеет приоритет над playerModelType. + if (this._skinsConfig.default) { + const d = this._skinsConfig.default; + this._playerModelType = (d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')) + ? d : ('skin_' + d); + } + } else { + this._skinsConfig = null; + } + // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — // PlayerController.start() её ждёт, но если предзагрузить сейчас, // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. @@ -7251,36 +7280,7 @@ export class BabylonScene { } } } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } - // Тип модели персонажа. - // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g'). - // Теперь стандарт — R15-скин bacon-hair. Если в проекте старая - // Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем. - if (state.scene.playerModelType) { - const pmt = state.scene.playerModelType; - if (pmt.startsWith('character-')) { - this._playerModelType = 'skin_bacon-hair'; - } else { - this._playerModelType = pmt; - } - } - // Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }. - if (state.scene.skins && typeof state.scene.skins === 'object') { - this._skinsConfig = { - default: state.scene.skins.default || null, - unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [], - shopVisible: state.scene.skins.shopVisible !== false, - coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0, - customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [], - }; - // Стартовый скин из skins.default имеет приоритет над playerModelType. - if (this._skinsConfig.default) { - const d = this._skinsConfig.default; - this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:') - ? d : ('skin_' + d); - } - } else { - this._skinsConfig = null; - } + // (Тип модели персонажа и skins решены выше — до предзагрузки модели.) // Пользовательские скрипты if (Array.isArray(state.scene.scripts)) { this._scripts = state.scene.scripts -- 2.47.2 From fe23d099cddcbef97e262c99a1d26851b19bba6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 12:16:37 +0300 Subject: [PATCH 03/13] =?UTF-8?q?feat(player):=20hud.setHotbarVisible=20/?= =?UTF-8?q?=20hud.setHpVisible=20(=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=BE=20=D1=81=D1=82=D1=83=D0=B4=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Игры со студии (1995/2037/2046) звали game.hud.setHotbarVisible/setHpVisible — в движке плеера были только hud.setVisible (весь HUD). Без них скрипт падал на первой строке и игра не работала (нет монет, кнопки не жмутся). Добавлено во все 3 слоя: - ScriptSandboxWorker: методы hud.setHotbarVisible/setHpVisible → _send - GameRuntime: обработчики cmd hud.setHotbarVisible/setHpVisible - BabylonScene: _setHotbarVisible/_setHpVisible + колбэки видимости Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine/BabylonScene.js | 12 ++++++++++++ src/engine/GameRuntime.js | 8 ++++++++ src/engine/ScriptSandboxWorker.js | 9 +++++++++ 3 files changed, 29 insertions(+) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 0d3b9a1..0b19b10 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -5956,6 +5956,18 @@ export class BabylonScene { try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} } + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */ + _setHotbarVisible(visible) { + this._hotbarVisible = !!visible; + try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {} + } + + /** Скрыть/показать только HP-индикатор (полоска жизней). */ + _setHpVisible(visible) { + this._hpVisible = !!visible; + try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {} + } + /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ setOnCursorModeChange(cb) { diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index 92604a4..fc9ccaa 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -1962,6 +1962,14 @@ export class GameRuntime { } catch (e) {} return; } + if (cmd === 'hud.setHotbarVisible') { + try { this.scene3d?._setHotbarVisible?.(!!payload?.visible); } catch (e) {} + return; + } + if (cmd === 'hud.setHpVisible') { + try { this.scene3d?._setHpVisible?.(!!payload?.visible); } catch (e) {} + return; + } if (cmd === 'input.setCursorMode') { try { const mode = payload?.mode === 'ui' ? 'ui' : 'game'; diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index 0ac66fc..91df928 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -1910,6 +1910,15 @@ const game = { setVisible(visible) { _send('hud.setVisible', { visible: !!visible }); }, + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). + * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ + setHotbarVisible(visible) { + _send('hud.setHotbarVisible', { visible: !!visible }); + }, + /** Скрыть/показать только HP-индикатор (полоска жизней). */ + setHpVisible(visible) { + _send('hud.setHpVisible', { visible: !!visible }); + }, }, /** * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). -- 2.47.2 From 7b869c83bd7817061388fac8eed032fba207364c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 12:32:34 +0300 Subject: [PATCH 04/13] =?UTF-8?q?fix(player):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=BF=D0=BE=203D-=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=20=D0=B2=20third-person=20(=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BE=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _handlePlayClick пикал билборд из ЦЕНТРА экрана (w/2,h/2) — верно только при pointer-lock. В third курсор свободен, юзер кликает мышью НЕ в центре → pick промахивался, кнопки табличек не нажимались (Ферма 1981 и др). Фикс: onMouseDown передаёт реальные canvas-координаты клика в _handlePlayClick(clickX,clickY); при locked — центр, иначе — точка клика. Добавлен console.log [billboard] для диагностики попадания. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine/BabylonScene.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 0b19b10..ecb9c50 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -2183,8 +2183,12 @@ export class BabylonScene { const onMouseDown = (e) => { if (this._isPlaying) { // В Play-режиме ЛКМ — клик игрока в forward-направлении. - // Pointer Lock — курсор всё равно в центре экрана. - if (e.button === 0) this._handlePlayClick(); + // При pointer-lock курсор в центре; в third (свободный курсор) + // передаём реальные координаты клика для pick по табличкам. + if (e.button === 0) { + const r = canvas.getBoundingClientRect(); + this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); + } return; } // Обновляем pointer координаты для raycast и Gizmo @@ -2913,7 +2917,7 @@ export class BabylonScene { * - в self-обработчики скриптов (routeEvent с target) * - в глобальные обработчики (game.onClick) с event.target */ - _handlePlayClick() { + _handlePlayClick(clickX, clickY) { if (!this._isPlaying) return; // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. @@ -2936,12 +2940,17 @@ export class BabylonScene { if (!this.gameRuntime) return; // === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) === - // Пикаем из центра экрана (как _pickFromCenter — в Play обычно - // pointer-lock). Если попали в кнопку таблички → fireClick и выходим. + // При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем + // из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам + // клика (clickX/clickY переданы из onMouseDown). Без этого клик по + // табличке мышью в third промахивался — кнопки не нажимались. if (this.billboardUiManager && this.primitiveManager) { + const locked = (document.pointerLockElement === this.canvas); const w = this.engine?.getRenderWidth?.() || this.canvas.width; const h = this.engine?.getRenderHeight?.() || this.canvas.height; - const bpick = this.scene.pick(w / 2, h / 2, (m) => + const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2); + const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2); + const bpick = this.scene.pick(px, py, (m) => m && m.metadata && m.metadata.primitiveId != null && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'); if (bpick && bpick.hit && bpick.pickedMesh) { @@ -2949,10 +2958,16 @@ export class BabylonScene { const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null; if (bdata && uv) { const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y); + console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId + + ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId + + ' locked=' + locked); if (buttonId) { this.billboardUiManager.fireClick(bdata, buttonId); return; // клик по табличке обработан } + } else { + console.log('[billboard] попал в табличку id=' + + bpick.pickedMesh.metadata.primitiveId + ' но нет UV'); } } } -- 2.47.2 From 66375e26c84bd4198afbb44b443df5a34b7432e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 12:45:06 +0300 Subject: [PATCH 05/13] =?UTF-8?q?fix(player):=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=2002=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=B4=D0=B8=D0=B8=20+=20=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=20=D1=80=D0=B0=D0=BD=D0=B4=D0=BE=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Привёл _setupInput/onPointerLockChange к рабочей студийной реализации: - onCanvasMouseDownGlobal/onWindowMouseUpGlobal — ПКМ-orbit с проверкой needPermLock() (как в студии), вместо самодельных onRmbDown/onRmbUp. - onPointerLockChange: при потере lock выход из Play (меню) ТОЛЬКО если needPermLock (first/lockfirst/sideview/shiftLock). В third потеря lock = отпустили ПКМ → остаёмся в Play. Это убирает рандомное открытие меню. - onCanvasClick лочит только в perma-режимах. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/engine/PlayerController.js | 78 +++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index bd966e9..68dcc31 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -2212,13 +2212,23 @@ export class PlayerController { _setupInput() { const canvas = this.canvas; - // Задача 02: ЛКМ-клик НЕ берёт pointer-lock в third (курсор свободен для - // GUI/3D-табличек). Lock берётся только в perma-режимах (first/lock) — - // но там он уже взят в start(). В third lock включает только зажатая ПКМ. + // Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock. + const needPermLock = () => ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + const onCanvasClick = () => { + // В UI-режиме клик не перехватывает мышь. if (this._uiCursorMode) return; - if (this._active && this._isPermaLockMode() - && document.pointerLockElement !== canvas) { + if (!this._active) return; + // Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся + // свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов + // где курсор постоянно скрыт, и только если lock был снят. + if (!needPermLock()) return; + if (document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); @@ -2227,31 +2237,33 @@ export class PlayerController { }; canvas.addEventListener('click', onCanvasClick); - // Задача 02: ПКМ-orbit — зажал ПКМ в third → lock + камера крутится, - // отпустил → курсор вернулся. В perma-режимах ПКМ не нужен. - const onRmbDown = (e) => { - if (e.button !== 2 || this._uiCursorMode) return; - if (!this._isPermaLockMode() && document.pointerLockElement !== canvas) { - this._rmbHeld = true; + // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру === + // Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся. + const onCanvasMouseDownGlobal = (e) => { + if (!this._active || this._uiCursorMode) return; + if (e.button !== 2) return; // только ПКМ + if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает + this._rmbHeld = true; + if (document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch (err) { /* ignore */ } } + e.preventDefault(); }; - const onRmbUp = (e) => { + const onWindowMouseUpGlobal = (e) => { if (e.button !== 2) return; - if (this._rmbHeld) { - this._rmbHeld = false; - if (!this._isPermaLockMode() && document.pointerLockElement === canvas) { - try { document.exitPointerLock(); } catch (err) { /* ignore */ } - } + if (!this._rmbHeld) return; + this._rmbHeld = false; + if (needPermLock()) return; + if (document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) { /* ignore */ } } }; - const onCtxMenu = (e) => { if (!this._uiCursorMode) e.preventDefault(); }; - canvas.addEventListener('mousedown', onRmbDown); - document.addEventListener('mouseup', onRmbUp); - canvas.addEventListener('contextmenu', onCtxMenu); + canvas.addEventListener('mousedown', onCanvasMouseDownGlobal); + window.addEventListener('mouseup', onWindowMouseUpGlobal); + canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); }); // === UI-режим: mousedown / mouseup → callback (для drag-игр) === const onCanvasMouseDown = (e) => { @@ -2343,18 +2355,26 @@ export class PlayerController { let wasLocked = false; const onPointerLockChange = () => { const locked = document.pointerLockElement === canvas; - this._applyCursorVisibility(); // задача 02: вернуть/скрыть курсор + this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор if (locked) { wasLocked = true; + this._rmbHeld = true; // если попали в lock — ПКМ удерживается } else if (wasLocked && this._active) { + // pointer-lock снят. Причин три: + // 1) пользователь в UI-режиме (game.input.setCursorMode('ui')) + // 2) ПКМ отпущена в third-person (orbit-камера завершена) + // 3) Esc → выход из Play (если был в first/lockfirst/sideview/shift) wasLocked = false; - if (this._uiCursorMode) return; - // Задача 02: в third потеря lock = отпустили ПКМ (orbit) ИЛИ - // вышли зумом из first — это НЕ выход из Play. Выход (Esc) только - // из perma-режимов с постоянным lock (first/lockfirst/shift-lock). - if (this._rmbHeld) { this._rmbHeld = false; return; } - if (this._cameraMode === 'third' || this._cameraMode === 'front') return; - if (this._onExitRequest) this._onExitRequest(); + this._rmbHeld = false; + if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; } + if (needPermLock()) { + // Был режим с постоянным lock'ом и его сняли (Esc) → выход. + if (this._onExitRequest) this._onExitRequest(); + } else { + // Third-person: просто отпустили ПКМ. Остаёмся в Play, + // курсор вернулся — это НЕ повод открывать меню. + this._applyCursorVisibility?.(); + } } }; document.addEventListener('pointerlockchange', onPointerLockChange); -- 2.47.2 From e26f85457845199a947ae673b563d62cb9d892c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 13:15:09 +0300 Subject: [PATCH 06/13] =?UTF-8?q?fix(player):=20=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=9F=D0=9A=D0=9C=20(orbit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React onLockChange в KubikonPlayer считал ЛЮБУЮ потерю pointer-lock за нажатие ESC и открывал меню. В third отпускание ПКМ (orbit-камера) тоже снимает lock → меню выскакивало на каждый поворот камеры. Теперь меню открывается только если lock потерян в perma-режиме (first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC. В third отпускание ПКМ игнорируется. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/KubikonPlayer/KubikonPlayer.jsx | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index c23dcf3..c4b6540 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -711,15 +711,25 @@ const KubikonPlayer = () => { const s = sceneRef.current; if (!s || !s._isPlaying) return; const locked = !!document.pointerLockElement; - // Lock потерян, мы НЕ в UI-cursor mode → пользователь нажал ESC - if (!locked && s.player && !s.player._uiCursorMode) { - // Синхронно ставим флаг — listener PlayerController сработает - // следующим и увидит true, не вызовет _onExitRequest. - s.player._uiCursorMode = true; - // Открываем меню в следующий тик (state-update React) - setChatOpen(false); - setTopMenuOpen(true); - } + if (locked || !s.player || s.player._uiCursorMode) return; + // Lock потерян. НЕ всякая потеря = ESC! В third-person отпускание + // ПКМ (orbit-камера) тоже снимает lock — это НЕ выход в меню. + // Меню открываем ТОЛЬКО если lock был «постоянным» (perma-режим: + // first/lockfirst/sideview/shiftLock) — там потеря lock = реальный ESC. + const p = s.player; + const permaLock = ( + p._cameraMode === 'first' || + p._cameraMode === 'lockfirst' || + p._cameraMode === 'sideview' || + p._shiftLock + ); + // _rmbHeld был выставлен при входе в lock; если ПКМ отпущена в third — + // это orbit-завершение, не меню. + if (!permaLock) return; + // Реальный ESC в perma-режиме → открываем меню. + p._uiCursorMode = true; + setChatOpen(false); + setTopMenuOpen(true); }; // capture-фаза, чтобы успеть раньше PlayerController document.addEventListener('pointerlockchange', onLockChange, true); -- 2.47.2 From f794fbe2d4c235b882d61e111e273b2ecef9ce15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 13:30:37 +0300 Subject: [PATCH 07/13] =?UTF-8?q?fix(player):=20=D1=87=D0=B0=D1=82=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B0=D1=8F=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B0=D0=BD=D0=B3=D0=BB.=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST-fallback чата при ошибке email_not_confirmed попадал в else-ветку и показывал сырой код email_not_confirmed (англ). WS-путь уже показывал русскую модалку EmailConfirmNotice. Добавил ту же ветку в REST-catch: email_not_confirmed → setEmailNotice(true). Игра 2046 не-мультиплеерная, чат часто идёт REST-фоллбэком → баг был виден именно там. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/KubikonPlayer/KubikonChatPanel.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/KubikonPlayer/KubikonChatPanel.jsx b/src/KubikonPlayer/KubikonChatPanel.jsx index e35310b..96c2695 100644 --- a/src/KubikonPlayer/KubikonChatPanel.jsx +++ b/src/KubikonPlayer/KubikonChatPanel.jsx @@ -363,6 +363,10 @@ const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false, is_manual: data.is_manual, }); setError(data.message || formatMuteMessage(data)); + } else if (code === 'email_not_confirmed') { + // То же поведение что и в WS-пути: русская модалка «подтвердите + // email», а не сырой английский код ошибки. + setEmailNotice(true); } else if (code === 'too_frequent') { setError(data.message || 'Слишком быстро.'); } else if (code === 'login_required') { -- 2.47.2 From bec3ee830c365fb324ef60f75020814357a9a6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 14:06:56 +0300 Subject: [PATCH 08/13] =?UTF-8?q?fix(player):=20=D1=81=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BA=D0=B8=D0=BD=D0=B0=20-=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=BD=D1=8F=D0=B5=D1=82=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20JSON=20+=20rublox/avatars=20(?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B8=D1=81=D1=87=D0=B5=D0=B7=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BA=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20setSkin=20non-humanoid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/PlayerController.js | 56 +++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/engine/PlayerController.js b/src/engine/PlayerController.js index 68dcc31..a741629 100644 --- a/src/engine/PlayerController.js +++ b/src/engine/PlayerController.js @@ -653,45 +653,51 @@ export class PlayerController { */ async _loadSkinManifest() { if (this._skinManifest) return this._skinManifest; - // 2026-05-27: сначала пробуем БД (rublox_avatars), там и легаси и - // дизайнерские аватары после approve. Только при сетевой ошибке — - // fallback на статичный manifest.json. + // ВАЖНО: объединяем ОБА источника, а не «или-или». + // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался + // ТОЛЬКО он, а статичный skins_manifest.json (где встроенные + // non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut + // и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback + // на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF + // (Unexpected magic) → старая модель уже выгружена, новая не создаётся → + // скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары. + let combined = []; + // 1) Статичный JSON (встроенные скины, включая non-humanoid). + try { + const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); + if (resp.ok) { + const json = await resp.json(); + if (Array.isArray(json.skins)) combined = combined.concat(json.skins); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] skins_manifest load failed:', e); + } + // 2) БД rublox_avatars (легаси + дизайнерские аватары после approve). try { const resp = await fetch(_storysApiUrl('/rublox/avatars')); if (resp.ok) { const json = await resp.json(); const items = json.items || []; - // Нормализуем под формат старого manifest: - // {id, file (без /kubikon-assets/ префикса), overrides} - // — потому что _resolveModelSource дальше добавляет - // '/kubikon-assets/' + entry.file. - // Дизайнерский file_path может быть /api-storys/... — оставляем - // как есть и добавляем спец-флаг entry.absolute_file=true, - // _resolveModelSource учтёт. - this._skinManifest = items.map((a) => ({ + // Нормализуем: file уже полный путь (absolute_file=true), т.к. + // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс. + const avatars = items.map((a) => ({ id: a.code, name: a.name, file: a.file_path, overrides: a.overrides || {}, - absolute_file: true, // file уже полный путь, не resolve через /kubikon-assets/ + absolute_file: true, })); - if (this._skinManifest.length > 0) return this._skinManifest; + // Аватары имеют приоритет при совпадении id — кладём в начало. + const avatarIds = new Set(avatars.map((a) => a.id)); + combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id))); } } catch (e) { // eslint-disable-next-line no-console - console.warn('[PlayerController] /rublox/avatars failed, fallback to manifest.json:', e); + console.warn('[PlayerController] /rublox/avatars failed:', e); } - // Fallback на статичный JSON - try { - const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); - const json = await resp.json(); - this._skinManifest = json.skins || []; - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] skins_manifest load failed:', e); - this._skinManifest = []; - } - return this._skinManifest; + this._skinManifest = combined; + return combined; } /** -- 2.47.2 From 8f0524cbb3310b89279e85a1e33a9b7a7ecd972d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sat, 30 May 2026 21:46:24 +0300 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D1=80=D1=82=203D-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B5=D0=BB=D0=BA=D0=B8-=D1=83=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=B5=D1=80=20(=D1=84=D0=B8=D1=87=D0=B0-=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D1=81=D1=82=D1=8C)=20+=20dev=20JWT-=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game.fx.pointer + расширенный game.fx.beam: BeamManager (текстуры/curved/ градиент/quest-marker), ScriptSandboxWorker (_normFxPoint от DataCloneError), GameRuntime (fx.createPointer/pointerTarget/pointerUpdate/beamUpdate/ beamVisible), BabylonScene._activatePointers. 1-в-1 со студией. - Dev JWT-панель на экране «Нужен JWT» (только localhost): кнопка → инпут → localStorage.player_jwt + reload. Co-Authored-By: Claude Opus 4.8 --- .env.production | 5 + API_USAGE.md | 104 ++++ src/App.jsx | 3 +- src/KubikonPlayer/GameMenu.jsx | 24 +- src/KubikonPlayer/KubikonPlayer.jsx | 26 +- src/LoadingScreen.jsx | 103 +++- src/engine/BabylonScene.js | 66 +++ src/engine/BeamManager.js | 711 +++++++++++++++++++++++++--- src/engine/GameRuntime.js | 58 +++ src/engine/README.md | 109 +++++ src/engine/ScriptSandboxWorker.js | 70 ++- 11 files changed, 1204 insertions(+), 75 deletions(-) create mode 100644 .env.production create mode 100644 API_USAGE.md create mode 100644 src/engine/README.md diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..59a4c78 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +VITE_API_BASE=https://minecraftia-school.ru +VITE_REALTIME_HTTP=https://minecraftia-school.ru/api-game +VITE_REALTIME_WS=wss://minecraftia-school.ru/api-game +VITE_RUBLOX_HOME=https://rublox.pro/app +VITE_STANDALONE=false diff --git a/API_USAGE.md b/API_USAGE.md new file mode 100644 index 0000000..53a7f6a --- /dev/null +++ b/API_USAGE.md @@ -0,0 +1,104 @@ +# API, которые использует плеер + +Плеер — это **клиент**. Все данные он берёт с серверных API. Этот документ — полный список эндпоинтов которые он дёргает, зачем и от чего ломается если API изменится. + +## Базовый URL + +В prod: `https://api.rublox.pro` (alias на `minecraftia-school.ru/api-storys`) +В dev: `https://dev-api.rublox.pro` (staging, см. [reference-staging-env]) +Локально: `http://localhost:8674` (storys-микросервис) + +## Эндпоинты — список + +### Игры и лента + +| Method | Path | Когда зовётся | Сломается если... | +|---|---|---|---| +| GET | `/kubikon3d/feed?tab=hot\|new\|popular\|topweek` | при заходе на главную | поле `games[]` отсутствует | +| GET | `/kubikon3d/trending` | главная, секция трендов | возвращает не-массив | +| GET | `/kubikon3d/search?q=` | поиск игр | поле `results[]` отсутствует | +| GET | `/kubikon3d/collections` | главная, секции «Хиты», «Новинки» | возвращает не-массив | +| GET | `/kubikon3d/projects/` | при открытии конкретной игры | поле `project_data` отсутствует или поломан JSON-формат | +| GET | `/kubikon3d/my-projects` | страница «Мои игры» | в ответе нет `projects[]` | +| GET | `/kubikon3d/top-authors` | страница «Топ авторов» | возвращает не-массив | +| GET | `/kubikon3d/events` | главная, баннер мероприятий | возвращает не-массив | + +### Игровой процесс (telemetry) + +| Method | Path | Когда | Что шлёт | +|---|---|---|---| +| POST | `/kubikon3d/play/heartbeat` | каждые 30 сек пока игрок в игре | `{ game_id, play_time_ms }` | +| POST | `/kubikon3d/activity` | при входе/выходе из игры | `{ game_id, action: 'enter'\|'leave' }` | +| POST | `/kubikon3d/perf-log` | при детектировании просадки FPS <20 на 5+ сек | `{ game_id, fps, draw_calls, mem }` | +| POST | `/kubikon3d/bug-reports` | юзер жмёт «Сообщить о баге» | `{ game_id, description, screenshot_b64 }` | +| POST | `/kubikon3d/reports` | юзер репортит игру | `{ game_id, reason, comment }` | + +### Скины и аватары (Рублокс-персонажи) + +| Method | Path | Когда | +|---|---|---| +| GET | `/kubikon3d/rublox/equipped-skin/` | при спавне аватара любого игрока | +| GET | `/kubikon3d/rublox/owned-skins` | страница «Мои скины» | +| POST | `/kubikon3d/rublox/equipped-skin` | юзер сменил скин | +| GET | `/api-storys/rublox/avatars/` | список аватаров юзера | +| GET | `/api-storys/rublox/outfit/` | детали одежды | +| GET | `/kubikon-assets/characters/skins_manifest.json` | при загрузке плеера, список всех доступных скинов | + +### Эмоушены (R15 анимации) + +| Method | Path | Когда | +|---|---|---| +| GET | `/api-storys/rublox/emotes/list` | при заходе в игру (для меню эмоушенов) | +| POST | `/api-storys/rublox/emotes/play/` | юзер выбрал эмоушен | + +### Модели и ассеты + +| Method | Path | Когда | +|---|---|---| +| GET | `/kubikon3d/models/public` | при загрузке игры — список public GLB-моделей | +| GET | `/kubikon3d/models/mine` | в редакторе (плеер запускает превью своих моделей) | +| GET | `/kubikon3d/models/` | при первом упоминании модели в проекте | + +### Админка (только видна юзерам с ролью admin) + +Все `/kubikon3d/admin/*` доступны только если в JWT юзер имеет роль `admin`. Иначе бэк возвращает 403. + +| Method | Path | Что показывает | +|---|---|---| +| GET | `/kubikon3d/admin/dashboard` | сводка: онлайн, активные игры | +| GET | `/kubikon3d/admin/online` | список онлайн-юзеров | +| GET | `/kubikon3d/admin/all-games` | все игры с фильтрами | +| GET | `/kubikon3d/admin/moderation-queue` | очередь премодерации (для review-игр) | +| GET | `/kubikon3d/admin/reports` | репорты на игры | +| GET | `/kubikon3d/admin/bug-reports` | баг-репорты | +| GET | `/kubikon3d/admin/comments` | модерация комментариев | +| GET | `/kubikon3d/admin/chat/messages` | чат-сообщения | +| GET | `/kubikon3d/admin/chat/bans` | список банов в чате | +| GET | `/kubikon3d/admin/authors` | топ авторов с детальной статистикой | + +### Multiplayer (Colyseus) + +WebSocket-соединение, не HTTP. Адрес: `wss://multiplayer.rublox.pro/`. Сейчас работает через `kubikon-realtime` микросервис (VM 110, port 8685, Node.js+Colyseus+Redis). + +## Аутентификация + +Все приватные эндпоинты ожидают **JWT в заголовке** `Authorization: Bearer `. JWT выдаёт `/api-user/auth/login`. Срок жизни access-токена — 1 час, refresh-токена — 30 дней. + +В плеере токен хранится в `localStorage.jwt`, рефрешится автоматически при 401 через `localStorage.refresh_token` → `/api-user/auth/refresh`. + +## Изменения API + +**До publish:** если меняешь сигнатуру эндпоинта (убираешь поле, переименовываешь, меняешь тип) — это **breaking change**. Объявление за 48 часов в канале `#разработка` на https://team.rublox.pro. + +Записывай в `API_CHANGELOG.md` админ-репо (приватный, `minecraftia-school.ru/...`) — это позволяет отследить какие изменения когда были. + +## Что делать если эндпоинт пропал + +1. Открой issue в репо плеера: «API endpoint /xxx not found». +2. Прикрепи console-лог с ошибкой. +3. Кто-то из core-команды посмотрит в `API_CHANGELOG.md` админ-репо и ответит — это было умышленное изменение или баг. + +## Контакты + +- Issue tracker: https://git.rublox.pro/rublox/player/issues +- Чат: `#разработка` на https://team.rublox.pro diff --git a/src/App.jsx b/src/App.jsx index 3c292fa..28433b3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -38,7 +38,8 @@ function PlayerRoute() { return ( ); } diff --git a/src/KubikonPlayer/GameMenu.jsx b/src/KubikonPlayer/GameMenu.jsx index c6cfe92..b6e1119 100644 --- a/src/KubikonPlayer/GameMenu.jsx +++ b/src/KubikonPlayer/GameMenu.jsx @@ -1217,6 +1217,17 @@ function TabReport({ gameId, gameTitle }) { setStatus({ text: 'Отправка...', error: false }); try { const token = getToken(); + // Бэкенд /kubikon3d/reports требует reporter_user_id и target_id и + // принимает поле text. Раньше TabReport слал {title, message, + // game_id, game_title} БЕЗ reporter_user_id → бэк отвечал + // 400 'reporter_user_id required' → жалоба падала. Приводим к + // формату бэкенда (как нижняя кнопка «Жалоба»). + const me = getMyProfile(); + if (!me || !me.id) { + setStatus({ text: 'Войдите, чтобы отправить жалобу.', error: true }); + setSending(false); + return; + } const res = await fetch(`${STORYS_addres}/kubikon3d/reports`, { method: 'POST', headers: { @@ -1224,11 +1235,12 @@ function TabReport({ gameId, gameTitle }) { ...(token ? { Authorization: token } : {}), }, body: JSON.stringify({ + reporter_user_id: me.id, + target_type: 'project', + target_id: gameId || null, category, - title: title.trim(), - message: message.trim(), - game_id: gameId || null, - game_title: gameTitle || null, + text: title.trim() + '\n\n' + message.trim() + + (gameTitle ? `\n\n(игра: ${gameTitle})` : ''), }), }); if (res.ok) { @@ -1236,7 +1248,9 @@ function TabReport({ gameId, gameTitle }) { setTitle(''); setMessage(''); } else { - setStatus({ text: 'Не удалось отправить. Попробуйте позже.', error: true }); + let detail = ''; + try { const j = await res.json(); if (j && j.error) detail = ': ' + j.error; } catch (e) {} + setStatus({ text: 'Не удалось отправить' + detail + '.', error: true }); } } catch (e) { setStatus({ text: 'Сеть недоступна.', error: true }); diff --git a/src/KubikonPlayer/KubikonPlayer.jsx b/src/KubikonPlayer/KubikonPlayer.jsx index c4b6540..7ac0043 100644 --- a/src/KubikonPlayer/KubikonPlayer.jsx +++ b/src/KubikonPlayer/KubikonPlayer.jsx @@ -505,22 +505,26 @@ const KubikonPlayer = () => { }); scene.setOnPlayChange?.((playing) => { setIsPlaying(playing); - // ESC обрабатывается через pointerlockchange-перехват в плеере - // (см. отдельный useEffect ниже). Сюда мы попадаем только если - // exitPlayMode вызвался по другой причине — тогда просто открываем - // меню, чтобы пользователь мог выйти/вернуться, и пересоздаём Play - // в UI-cursor режиме. + // ВНИМАНИЕ: при обычном ESC сюда мы больше НЕ попадаем — ESC теперь + // открывает меню через setOnEscMenu (ниже), не выходя из Play. + // exitPlayMode(false) случается только по-настоящему (напр. движок + // сам остановил Play). В этом случае просто открываем меню, чтобы + // юзер мог выйти/перезапустить. НЕ пересоздаём Play автоматически — + // повторный enterPlayMode респавнил игрока и перезапускал скрипты + // («перезапуск плейса» при ESC). Перезапуск делается явной кнопкой. if (!playing) { - setTimeout(() => { - const s = sceneRef.current; - if (!s) return; - s.enterPlayMode?.(); - s.player?.setUiCursorMode?.(true); - }, 30); + const s = sceneRef.current; + s?.player?.setUiCursorMode?.(true); setChatOpen(false); setTopMenuOpen(true); } }); + // ESC в Play → меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не + // прерывается, скрипты продолжают идти, игрок не респавнится. + scene.setOnEscMenu?.(() => { + setChatOpen(false); + setTopMenuOpen(true); + }); // Загружаем проект. // STANDALONE-режим (VITE_STANDALONE=true) подсовывает встроенный diff --git a/src/LoadingScreen.jsx b/src/LoadingScreen.jsx index 0cde05b..73ce609 100644 --- a/src/LoadingScreen.jsx +++ b/src/LoadingScreen.jsx @@ -9,11 +9,62 @@ // // CSS-анимации, без JS-фрейма каждый кадр. -import React from 'react'; +import React, { useState } from 'react'; const TITLE = 'Рублокс'; -export default function LoadingScreen({ text = 'Подключение', subText = null }) { +/** + * Dev-only панель вставки JWT. Показывается на экране «Нужен JWT» (только + * localhost). Кнопка → инпут → сохраняет в localStorage['player_jwt'] и + * перезагружает страницу. На проде этот экран не наступает (там redirect). + */ +function DevJwtPanel() { + const [open, setOpen] = useState(false); + const [val, setVal] = useState(''); + + const apply = () => { + const t = (val || '').trim(); + if (!t) return; + try { + localStorage.setItem('player_jwt', t); + // совместимость с другими местами чтения токена + localStorage.setItem('Authorization', t); + } catch (e) { /* ignore */ } + window.location.reload(); + }; + + if (!open) { + return ( + + ); + } + return ( +
+