fix(player): меню ESC — toggle вместо открытия поверх + чинит orbit-камеру по ПКМ
All checks were successful
CI / Build (pull_request) Successful in 1m35s
CI / Secret scan (pull_request) Successful in 2m29s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Lint (pull_request) Successful in 58s

Два бага меню в плеере:
1. Повторный ESC открывал меню ПОВЕРХ первого (не закрывал).
2. После открытия/закрытия меню переставала работать orbit-камера по
   зажатой ПКМ (игры задачи 2 camera_mouse_controls).

Первопричина: ESC слушали ДВА обработчика — движок (setOnExitRequest →
_onEscMenu) и React (отдельный keydown при topMenuOpen). На одно нажатие
срабатывали оба → гонка: меню дублировалось, а _uiCursorMode застревал в
true, из-за чего onCanvasMouseDownGlobal (if _uiCursorMode return) игнорировал
ПКМ → orbit-камера не включалась.

Фикс — единый источник истины в движке:
- BabylonScene: флаг _playerMenuOpen + toggle в setOnExitRequest (открыто→
  закрыть+setUiCursorMode(false), закрыто→открыть). _onEscMenu(open) передаёт
  состояние в UI. setPlayerMenuOpen(open) — синхронизация при закрытии из UI
  (кнопка «Продолжить»). Сброс флага в enterPlayMode.
- KubikonPlayer: setOnEscMenu((open)=>setTopMenuOpen(open)); УБРАН дублирующий
  React ESC-обработчик; onClose меню → setPlayerMenuOpen(false); синхронизация
  _playerMenuOpen=true в onLockChange (perma) и setOnPlayChange.
- PlayerController.setUiCursorMode(true): сброс _rmbHeld=false (иначе если меню
  открыли при зажатой ПКМ, флаг застревал → orbit «думал» что ПКМ активна).

Проверено: ESC открыл→ESC закрыл (1 меню в DOM), ПКМ-orbit работает после меню.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
МИН 2026-05-31 09:55:58 +03:00
parent cd31078e6d
commit acb5b0b133
3 changed files with 62 additions and 29 deletions

View File

@ -517,13 +517,20 @@ const KubikonPlayer = () => {
s?.player?.setUiCursorMode?.(true);
setChatOpen(false);
setTopMenuOpen(true);
try { if (s) s._playerMenuOpen = true; } catch (e) { /* ignore */ }
}
});
// ESC в Play меню-оверлей поверх ЖИВОЙ игры (Roblox-style). Play не
// прерывается, скрипты продолжают идти, игрок не респавнится.
scene.setOnEscMenu?.(() => {
setChatOpen(false);
setTopMenuOpen(true);
// ESC в Play TOGGLE меню-оверлея поверх ЖИВОЙ игры (Roblox-style).
// Движок сам решает open/close (единый источник истины _playerMenuOpen)
// и передаёт сюда. Это убирает гонку двух ESC-обработчиков, из-за которой
// меню открывалось поверх меню, а orbit-камера по ПКМ зависала.
scene.setOnEscMenu?.((open) => {
if (open) {
setChatOpen(false);
setTopMenuOpen(true);
} else {
setTopMenuOpen(false);
}
});
// Загружаем проект.
@ -734,26 +741,20 @@ const KubikonPlayer = () => {
p._uiCursorMode = true;
setChatOpen(false);
setTopMenuOpen(true);
// Синхронизируем единый флаг меню в движке, чтобы следующий ESC
// сработал как toggle-закрытие (а не открыл второе меню).
try { s._playerMenuOpen = true; } catch (e) { /* ignore */ }
};
// capture-фаза, чтобы успеть раньше PlayerController
document.addEventListener('pointerlockchange', onLockChange, true);
return () => document.removeEventListener('pointerlockchange', onLockChange, true);
}, []);
// Повторный ESC (когда меню уже открыто) закрыть меню и вернуть
// мышь в игру.
useEffect(() => {
if (!topMenuOpen) return;
const onEsc = (e) => {
if (e.key !== 'Escape') return;
const s = sceneRef.current;
if (!s || !s._isPlaying) return;
setTopMenuOpen(false);
s.player?.setUiCursorMode?.(false);
};
window.addEventListener('keydown', onEsc, true);
return () => window.removeEventListener('keydown', onEsc, true);
}, [topMenuOpen]);
// Повторный ESC (toggle закрытие) теперь обрабатывает движок через
// setOnExitRequest _onEscMenu(false). Отдельный React-обработчик ESC
// УБРАН он слушал тот же ESC, что и движок, и создавал гонку:
// меню открывалось поверх себя, а _uiCursorMode застревал в true
// (orbit-камера по ПКМ переставала работать после закрытия меню).
// Горячая клавиша T открыть/закрыть чат. Игнорируем когда:
// уже введён текст в <input>/<textarea>/contenteditable (юзер печатает)
@ -1619,9 +1620,10 @@ const KubikonPlayer = () => {
visible={topMenuOpen}
onClose={() => {
setTopMenuOpen(false);
// Возвращаем мышь в pointer-lock игры (как делал
// старый ESC-handler выше).
try { sceneRef.current?.player?.setUiCursorMode?.(false); } catch {}
// Синхронизируем движок (_playerMenuOpen) И возвращаем мышь
// в игру одним вызовом. Без этого следующий ESC решит, что
// меню «ещё открыто», и не откроет его.
try { sceneRef.current?.setPlayerMenuOpen?.(false); } catch {}
}}
onExit={() => exitPlayer(id)}
onRespawn={() => respawnPlayer()}

View File

@ -5332,6 +5332,7 @@ export class BabylonScene {
this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь».
if (this._touchState) this._touchState.clear();
this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play
// По умолчанию стандартный HUD видим в Play.
// Скрипт может скрыть через game.hud.setVisible(false).
this._setStdHudVisible(true);
@ -5392,14 +5393,24 @@ export class BabylonScene {
this.modalManager.close();
return;
}
// ESC в плеере = открыть меню-оверлей поверх ЖИВОЙ игры (как в Roblox).
// Раньше тут был exitPlayMode() + _onPlayChange(false), из-за чего
// KubikonPlayer заново звал enterPlayMode → игра перезапускалась
// (респавн + перезапуск скриптов). Теперь только UI-курсор + сигнал
// открыть меню. Play продолжает идти под меню.
// ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox).
// Единый источник истины — _playerMenuOpen в движке. Раньше состояние
// меню держал React, а ESC слушали ДВА обработчика (движок + React) →
// гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true
// → orbit-камера по ПКМ переставала работать после закрытия меню.
// Теперь движок сам решает open/close и шлёт это в _onEscMenu(open).
if (typeof this._onEscMenu === 'function') {
this.player?.setUiCursorMode?.(true);
this._onEscMenu();
if (this._playerMenuOpen) {
// Меню открыто → ESC закрывает: вернуть мышь в игру.
this._playerMenuOpen = false;
this.player?.setUiCursorMode?.(false);
this._onEscMenu(false);
} else {
// Меню закрыто → ESC открывает: освободить курсор.
this._playerMenuOpen = true;
this.player?.setUiCursorMode?.(true);
this._onEscMenu(true);
}
return;
}
// Фолбэк (если меню не подписано, напр. в студии) — старое поведение.
@ -5996,6 +6007,22 @@ export class BabylonScene {
this._onEscMenu = cb;
}
/**
* Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают
* НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить
* движку иначе _playerMenuOpen рассинхронизируется и следующий ESC решит,
* что меню «открыто», и не откроет его. open=false также возвращает мышь в игру.
*/
setPlayerMenuOpen(open) {
const v = !!open;
if (this._playerMenuOpen === v) return;
this._playerMenuOpen = v;
if (!v) {
// меню закрыли из UI → вернуть управление камерой/мышью
try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ }
}
}
/**
* Колбэк изменения сцены (любая модификация блоков/моделей).
* Используется KubikonEditor для dirty-tracking auto-save.

View File

@ -580,6 +580,10 @@ export class PlayerController {
setUiCursorMode(enabled) {
this._uiCursorMode = !!enabled;
if (enabled) {
// Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если
// меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit-
// камера после закрытия меню «думает», что ПКМ всё ещё активна.
this._rmbHeld = false;
// Освобождаем мышь
if (document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) { /* ignore */ }