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] =?UTF-8?q?fix(player):=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=203D-=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=87=D0=B5=D0=BA=20?= =?UTF-8?q?+=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=B2=20Roblox=20(=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=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(); } };