fix(player): кнопки 3D-табличек + управление камерой как в Roblox (задачи 01, 02)

Задача 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) <noreply@anthropic.com>
This commit is contained in:
МИН 2026-05-30 08:27:59 +03:00
parent e61c398eeb
commit 2afd6a287a
2 changed files with 114 additions and 12 deletions

View File

@ -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;

View File

@ -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();
}
};