diff --git a/src/api/Kubikon3DService.js b/src/api/Kubikon3DService.js index 144d4b2..f080cd6 100644 --- a/src/api/Kubikon3DService.js +++ b/src/api/Kubikon3DService.js @@ -193,8 +193,9 @@ export const getProjectForPlay = (id, userId = null, isAdmin = false) => }, }); -export const incrementPlay = (id) => - api.post(`/kubikon3d/projects/${id}/play`); +export const incrementPlay = (id, userId) => + api.post(`/kubikon3d/projects/${id}/play`, + userId ? { user_id: userId } : {}); /** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает, * голос другого типа — переключает. */ diff --git a/src/community/docsGames.js b/src/community/docsGames.js index 5a6aabd..1f69526 100644 --- a/src/community/docsGames.js +++ b/src/community/docsGames.js @@ -348,4 +348,9 @@ export const GAMES = [ desc: 'Живое 3D-меню как в топ-играх: камера кинематографично облетает премиум-машину в гараже, справа патч-ноуты, снизу кнопка ИГРАТЬ → переход в саму игру.', mechanics: ['game.mainMenu.show / hide', 'cinematic-камера (waypoints/orbit)', 'патч-ноуты + логотип + кнопка ИГРАТЬ', 'блок управления + фоновая музыка', 'onPlay → loading.transition → gameplay', 'GLB-модель машины (Kenney car-kit)'], previewShot: 'guide-garage-scene.png', openProjectId: 2434, ready: true }, + { id: 'guide-taxisim', num: 61, group: 'g5', stars: 3, icon: 'car', + title: 'Такси-симулятор — садись за руль', + desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.', + mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'], + previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true }, ]; diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx index 8b553c2..2001a36 100644 --- a/src/community/docsLessons.jsx +++ b/src/community/docsLessons.jsx @@ -8485,6 +8485,117 @@ step();`} ), }, + 'guide-taxisim': { + body: ( + <> +

Что получится

+

+ Настоящие машины, на которых можно ездить. Подходишь к + автомобилю → над ним появляется подсказка «[F] Enter», держишь + F → садишься за руль. WASD рулят (газ/тормоз/задний ход + + повороты), камера плавно следует за машиной, снизу — спидометр + с передачей (D/R/N). V меняет ракурс камеры, E — выйти. + Машина сталкивается со стенами и столбами. Это основа таксопарков, + гонок, доставки — 30% жанров Roblox держатся на транспорте. +

+ + + +

Чему научишься

+ + +

Шаг 1. Заспавнить машину

+

+ Одна строка создаёт готовый к езде автомобиль. model — + одна из встроенных 3D-моделей (car-taxi / car-sedan / car-truck / + car-suv-luxury и др.), name показывается в подсказке F. +

+ + {`game.scene.spawn('vehicle:car', { + x: -28, y: 0.5, z: -22, rotationY: 0, + model: 'car-taxi', color: '#ffd23a', name: 'Natsune Alltima', + params: { maxSpeed: 14, turnSpeed: 1.8, enginePower: 16, brake: 28 }, +});`} + + Сиденье водителя создаётся автоматически — отдельно регистрировать + «вход» не нужно. Подошёл к машине → подсказка «[F] Enter» с её именем. + Управление: W газ, S тормоз/назад, A/D руль, + Space ручник, V камера, E выйти. + + +

Шаг 2. Реакция на посадку (квесты, деньги)

+

+ Глобальные события onVehicleEnter / onVehicleExit + дают зацепку для логики игры — например, показать инструкцию или + начать отсчёт заказа такси. +

+ + {`game.onVehicleEnter((vehicleRef) => { + game.ui.set('hint', 'Поехали! Отвези клиента.', { x: 50, y: 88, anchor: 'bottom' }); +}); +game.onVehicleExit((vehicleRef) => { + game.ui.set('hint', 'Ты вышел из машины.', { x: 50, y: 88, anchor: 'bottom' }); +});`} + + + +

Шаг 3. Hold-to-action (защита от случайных нажатий)

+

+ Любое взаимодействие можно сделать «по удержанию» — игрок должен + подержать клавишу, а не случайно ткнуть. Полезно для важных действий + (подобрать клиента, продать машину). Опция holdDuration в + onInteract: +

+ + {`game.self.onInteract(() => { + game.ui.set('hint', 'Клиент сел!', { x: 50, y: 88, anchor: 'bottom' }); +}, { text: 'Подобрать клиента', distance: 5, key: 'f', holdDuration: 0.5 });`} + +

Что движок делает сам

+

+ Тебе не нужно писать «низкоуровневую» возню — движок берёт её на себя: +

+
    +
  • Машина сама встаёт на землю. Где бы ты ни задал спавн, + авто опускается на пол/дорогу — не висит в воздухе и не тонет;
  • +
  • Звук мотора. Пока едешь — слышен живой рокот двигателя: + чем быстрее, тем плотнее и громче. На стоянке — тихо;
  • +
  • Водитель скрывается за рулём и появляется сбоку при выходе;
  • +
  • Падение в бездну = автоматический выход + респавн на старте.
  • +
+ +

Почему это важно

+

+ Машина — отдельная подсистема: пока ты за рулём, WASD управляют + автомобилем, а не ходьбой, и камера автоматически переключается + на «погоню за машиной». Выход возвращает обычное управление. На этой + базе строятся целые игры: такси, гонки, доставка, мафия-симулятор. +

+ + + Заспавни вторую машину другой модели (model: 'car-suv-luxury') + с другим цветом и параметрами (быстрее: maxSpeed: 20). + Поставь рядом столб (game.scene.spawn('primitive:cylinder', ...)) + и проверь — машина останавливается, столб стоит. + + + ), + }, + }; /** Есть ли готовый текст урока для игры с таким id. */ diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index 57a0a01..ce59924 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -41,6 +41,8 @@ import { import { PlacementManager } from './PlacementManager'; import { ShopInventoryUi } from './ShopInventoryUi'; import { LoadingScreenOverlay } from './LoadingScreenOverlay'; +import { VehicleManager } from './VehicleManager'; +import { VehicleHud } from './VehicleHud'; import { BlockManager } from './BlockManager'; import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; // Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. @@ -154,6 +156,9 @@ export class BabylonScene { // импортировал их напрямую (избегаем циклических импортов). this.placementManager = null; this.shopInventoryUi = null; + this.vehicleManager = null; // задача 14 — система транспорта + this.vehicleHud = null; + this._VehicleHudClass = VehicleHud; this._PlacementManagerClass = PlacementManager; this._ShopInventoryUiClass = ShopInventoryUi; // Экран загрузки (задача 12) — DOM-оверлей + конфиг проекта. @@ -1273,6 +1278,8 @@ export class BabylonScene { // Voxel-террейн тоже участвует в физике. У террейна свой размер // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); + // Задача 14 — система транспорта (нужны physics + modelManager). + this.vehicleManager = new VehicleManager(this); // Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике. // Физика проверяет коллизии в обоих источниках (legacy terrainManager + // voxelWorld), что позволяет постепенно мигрировать без поломки. @@ -1917,6 +1924,24 @@ export class BabylonScene { if (typeof mesh.getBoundingInfo !== 'function') return; if (typeof mesh.getTotalVertices !== 'function') return; if (mesh.getTotalVertices() <= 0) return; + // ОПТИМИЗАЦИЯ ТЕНЕЙ: мелкие/тонкие меши (окна, пояски, разметка, детали + // мебели) НЕ кастят тень — их тень незаметна, но каждый caster дорого + // стоит в shadow-map (на сцене из сотен примитивов давало 5-15 FPS). + // Кастят тень только заметные объекты (дома, машины, деревья, столбы). + try { + const bb = mesh.getBoundingInfo().boundingBox; + const ext = bb.extendSizeWorld || bb.extendSize; // half-размеры + if (ext) { + const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2; + const maxDim = Math.max(w, h, d); + const minDim = Math.min(w, h, d); + // мелкий объект (всё < 1.6) ИЛИ очень тонкая пластина (< 0.35) + if (maxDim < 1.6 || minDim < 0.35) return; + // огромный плоский пол/дорога (> 30 по горизонтали и плоский) — + // не нужен как caster (только принимает тень). + if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return; + } + } catch (e) { /* если не смогли измерить — добавляем как раньше */ } try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } } @@ -7625,6 +7650,9 @@ export class BabylonScene { this.gameRuntime = null; } + // Задача 14: убрать машины + HUD водителя. + if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) { /* ignore */ } } + if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) { /* ignore */ } this.vehicleHud = null; } // Placement mode (задача 11): сброс активной сессии + виджета магазина. if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; } if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) { /* ignore */ } this.shopInventoryUi = null; } diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index a725c95..52fa64c 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -454,6 +454,12 @@ export class GameRuntime { this._objectData = {}; this._interactables = []; this._activeInteractRef = null; + // Задача 14: убрать машины и HUD водителя — без этого при повторном + // start (например двойной enterPlayMode) машины дублируются. + try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {} + try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {} + this._vehHudShown = false; + try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {} this._watchedTouchRefs = null; this._watchedClickRefs = null; this._roomState = {}; @@ -587,6 +593,9 @@ export class GameRuntime { // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом if (this._interactables.length > 0) this._updateInteractables(); + // Задача 14: HUD водителя — спидометр + передача, пока игрок в машине. + this._updateVehicleHud(); + // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть) const hp = this.scene3d?.player?.hp ?? 100; const aliveNow = hp > 0; @@ -755,8 +764,37 @@ export class GameRuntime { } } + /** Задача 14: HUD водителя — графический спидометр со стрелкой (SVG). */ + _updateVehicleHud() { + const player = this.scene3d?.player; + const veh = player?._inVehicle; + if (veh) { + const hud = this._ensureVehicleHud(); + if (hud) { + if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; } + try { hud.update(veh.speed); } catch (e) {} + } + } else if (this._vehHudShown) { + this._vehHudShown = false; + try { this.scene3d?.vehicleHud?.remove(); } catch (e) {} + } + } + + _ensureVehicleHud() { + if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud; + if (!this.scene3d || !this.scene3d._VehicleHudClass) return null; + try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); } + catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); } + return this.scene3d.vehicleHud || null; + } + /** Резолв позиции интерактивного объекта (по ref). */ _resolveInteractPos(it) { + // Задача 14: машина — позиция из VehicleManager. + if (it.ref && it.ref.indexOf('vehicle:') === 0) { + const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8))); + return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null; + } const tgt = this._resolveTweenTarget(it.ref); if (tgt) { const d = tgt.data; @@ -774,8 +812,29 @@ export class GameRuntime { if (!this._activeInteractRef) return; const it = this._interactables.find(x => x.ref === this._activeInteractRef); if (!it || it.key !== String(key).toLowerCase()) return; - // событие 'interact' скрипту с target = этим объектом - this.routeEvent(it.target, 'interact', {}); + this._fireInteract(it); + } + + /** Сработавший интеракт (мгновенный или по завершению hold). */ + _fireInteract(it) { + if (!it) return; + if (it.isInst) { + // findOne(ref).onInteract → instInteract в worker. + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref }); + } else if (it.target) { + this.routeEvent(it.target, 'interact', {}); + } + // Задача 14: вход в машину — если ref это vehicle и игрок не за рулём. + if (it.ref && it.ref.indexOf('vehicle:') === 0) { + const vid = Number(it.ref.slice(8)); + const veh = this.scene3d?.vehicleManager?.getById?.(vid); + const player = this.scene3d?.player; + if (veh && player && !player._inVehicle) { + player.enterVehicle(veh); + player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); }; + for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid }); + } + } } /** Прокрутка всех активных твинов на dt секунд. */ @@ -2935,6 +2994,8 @@ export class GameRuntime { text: payload.text || 'Взаимодействовать', distance: Number(payload.distance) || 4, key: payload.key || 'e', + holdDuration: Number(payload.holdDuration) || 0, + isInst: false, }); } } catch (e) { @@ -2942,6 +3003,23 @@ export class GameRuntime { } return; } + if (cmd === 'inst.registerInteract') { + // Задача 14: findOne(ref).onInteract — интеракт по ref (не target-скрипт). + try { + const ref = payload?.ref; + if (ref && !this._interactables.some(it => it.ref === ref)) { + this._interactables.push({ + ref, target: null, + text: payload.text || 'Взаимодействовать', + distance: Number(payload.distance) || 4, + key: payload.key || 'e', + holdDuration: Number(payload.holdDuration) || 0, + isInst: true, + }); + } + } catch (e) { /* ignore */ } + return; + } if (cmd === 'scene.setLabel') { try { const ref = payload?.ref; @@ -3779,6 +3857,33 @@ export class GameRuntime { } this.scheduleSceneSnapshot(); } + } else if (kind === 'vehicle') { + // Задача 14: машина. subType игнорируем, модель в payload.model. + const opts = payload; + const p = this.scene3d?.vehicleManager?.spawn({ + model: opts.model || 'car-sedan', color: opts.color, name: opts.name, + params: opts.params, x: opts.x, y: opts.y, z: opts.z, + rotationY: opts.rotationY || 0, ref, + }); + Promise.resolve(p).then((vid) => { + if (vid == null) return; + const realRef = 'vehicle:' + vid; + this._localToReal.set(ref, realRef); + this._notifySpawnResolved(ref, realRef); + // Авто-регистрация сиденья водителя как интерактивной зоны (F → сесть). + const veh = this.scene3d?.vehicleManager?.getById?.(vid); + if (veh && !this._interactables.some(it => it.ref === realRef)) { + this._interactables.push({ + ref: realRef, target: null, + text: 'Enter', objectName: veh.name, + distance: Math.max(4, veh.half.d + 2), key: 'f', + holdDuration: 0.4, isInst: true, isVehicle: true, + }); + } + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn vehicle failed: ' + (err?.message || err)); + }); } } catch (e) { this._log('error', 'scene.spawn failed: ' + (e?.message || e)); diff --git a/src/editor/engine/ModelManager.js b/src/editor/engine/ModelManager.js index 0c14924..d457be9 100644 --- a/src/editor/engine/ModelManager.js +++ b/src/editor/engine/ModelManager.js @@ -294,8 +294,12 @@ export class ModelManager { r.getChildMeshes(false).forEach(m => { m.isPickable = true; m.metadata = { isModel: true, instanceId: this._nextInstanceId }; - // Тени: GLB-модель принимает тени от мира. - m.receiveShadows = true; + // Тени: GLB-модель принимает тени от мира. На InstancedMesh + // receiveShadows не действует (Babylon-warning + лишняя работа) — + // ставим только на обычных мешах. + if (m.getClassName && m.getClassName() !== 'InstancedMesh') { + m.receiveShadows = true; + } clonedMeshes.push(m); }); // И сам root тоже на всякий diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js index 03c29ab..d94238a 100644 --- a/src/editor/engine/PlayerController.js +++ b/src/editor/engine/PlayerController.js @@ -1,2872 +1,3073 @@ -/** - * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения). - * - * Камера: - * 1st person — камера в позиции глаз игрока, модель невидима. - * 3rd person — камера сзади-сверху, модель видна. - * Переключение: клавиша C циклит first ↔ third. - * Колесо мыши в third-person — меняет дистанцию (zoom). - * - * Модель игрока: - * Грузим GLB через ModelManager (тот же что для editor-моделей). - * Корневой TransformNode хранит position/rotation модели. - * В 3rd person модель видна и крутится в направлении движения. - * Анимации (idle/walk/sprint) переключаются по скорости/спринту. - * - * Управление: - * W/A/S/D / стрелки — движение в горизонтальной плоскости - * Space — прыжок - * Shift — спринт (×1.7) - * C — переключить камеру 1st ↔ 3rd - * Esc — выйти из игры (через pointer-lock release) - */ -import { - Vector3, UniversalCamera, SceneLoader, TransformNode, - MeshBuilder, StandardMaterial, Color3, - Quaternion, Space, Ray, -} from '@babylonjs/core'; -import { getModelType } from './ModelTypes'; -import { R15Skeleton } from './R15Skeleton'; -import { R15Animator } from './R15Animator'; - -// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). -// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. -const CAMERA_MODES = ['third', 'first', 'front']; -// Для режима 'sideview' (Кубикон Dash): -// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z) -// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб -// и ~12м препятствий впереди влезали в кадр на 16:9. -const SIDEVIEW_DIST = 14; -const SIDEVIEW_HEIGHT = 2.5; - -export class PlayerController { - constructor(scene, canvas, physics, scene3d = null) { - this.scene = scene; - this.canvas = canvas; - this.physics = physics; - this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint) - this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали - - // AABB - this.HALF_W = 0.3; - this.HALF_H = 0.9; - this.HALF_D = 0.3; - this.EYE_HEIGHT = 0.7; // глаза от центра AABB - - this.WALK_SPEED = 4.5; - this.SPRINT_MULT = 1.7; - this.JUMP_VELOCITY = 8; - this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне) - this._speedMul = 1; // множитель скорости передвижения - this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная) - this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт) - this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе - this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) - this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) - this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) - // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. - // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. - this._autoRunSpeed = 0; - // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе). - // В sideview-камере при прыжке куб эффектно крутится — визитка GD. - this._dashSpinAngle = 0; - // Camera shake: amplitude + remaining time. Применяется в _tick после - // _computeCameraPos. Используется через game.camera.shake(amp, dur). - this._cameraShakeAmp = 0; - this._cameraShakeLeft = 0; - // Управление камерой из скрипта (Фаза 5.7). null = обычная камера - // игрока. Иначе объект режима: - // { mode:'focus', getTarget } — следить за объектом; - // { mode:'cutscene', points, durations, ... } — пролёт по точкам. - this._cameraOverride = null; - // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть. - // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround. - this._coyoteLeft = 0; - this._doubleJumpEnabled = false; - this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте» - // Кубикон Dash: направление гравитации. +1 = нормально (вниз), - // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD). - // Применяется только в sideview-режиме. Влияет на: - // - vy += GRAVITY * gravityDir * dt - // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз) - // - "onGround" определение: hitY + vy*gravityDir < 0 - // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте). - this._gravityDir = 1; - // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка, - // 1 = полностью скользко (инерция держится бесконечно). Реалистичный - // лёд = ~0.85. Настраивается через game.player.setIceFriction(value). - this._iceFriction = 0; - this._iceVelX = 0; - this._iceVelZ = 0; - // Присед — уменьшает высоту AABB. Включается через - // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45. - this._crouching = false; - this.HALF_H_NORMAL = 0.9; - this.HALF_H_CROUCH = 0.45; - this.GRAVITY = -22; - this.MOUSE_SENSITIVITY = 0.0025; - - // 3rd person camera (Roblox-style: 0.5 .. 32) - this.THIRD_DISTANCE_MIN = 0.5; - this.THIRD_DISTANCE_MAX = 32; - this.THIRD_DISTANCE_DEFAULT = 5; - this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока - // Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5) - this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; - // Lockfirst-режим: нельзя выйти из first-person зумом наружу - this._lockFirstPerson = false; - // Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере - // (включается клавишей L по дефолту, или game.player.setShiftLock(true)) - this._shiftLock = false; - // Видимость курсора по умолчанию (game.input.setMouseIconVisible) - this._mouseIconVisible = true; - // Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован) - // / 'lockcurrent' (зафиксирован на текущей позиции) - this._mouseBehavior = 'default'; - // Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third) - this._rmbHeld = false; - - this.camera = null; - this._active = false; - this._onExitRequest = null; - - // Состояние игрока - this._pos = new Vector3(0, 5, 0); - this._vy = 0; - this._yaw = 0; - this._pitch = 0; - - // Камера. Дефолт — первое лицо (как в большинстве игр). - this._cameraMode = 'third'; - this._thirdDistance = this.THIRD_DISTANCE_DEFAULT; - - // Ввод - this._codes = new Set(); - this._shift = false; - - // Auto-step visual smoothing. Когда PhysicsAABB телепортирует - // игрока вверх на уступ (steppedUpBy), физически он уже наверху, - // но мы интерполируем рендер: показываем модель и камеру со сдвигом - // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0. - // Получается визуально как плавный «полупрыжок» без рывка. - this._stepUpVisualOffset = 0; - // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс. - this._stepUpDecay = 4.5; - - // Модель игрока (грузится в start) - // Дефолт — R15-скин bacon-hair (классический Roblox-вид). - this._modelTypeId = 'skin_bacon-hair'; - this._modelRoot = null; - this._modelMeshes = []; - // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет - // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР - // в _tick (после анимаций), чтобы скрин оставался скрытым даже после - // асинхронной загрузки модели или после _applyCameraMode. - this._skinVisibleScripted = true; - this._animations = {}; - this._currentAnim = null; - // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока). - // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72. - this._modelScale = 0.72; - - // === R15-скин (bacon-hair и др.) === - // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций). - // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из - // characters//body.glb, детектируем скелет, анимируем - // процедурно через R15Animator (см. _loadPlayerModel / _tick). - this._isR15 = false; // флаг: загружен валидный R15-скелет - this._r15Skeleton = null; // R15Skeleton — резолвер костей - this._r15Animator = null; // R15Animator — процедурные анимации - this._skinManifest = null; // кеш skins_manifest.json - this._skinOverrides = {}; // overrides текущего скина - - // === Жизни игрока === - this.maxHp = 100; - this.hp = 100; - this._lastDamageTime = 0; - this._invulnerabilityTime = 0.5; // 500мс i-frames после удара - this._onHpChange = null; - this._onDeath = null; - - // Звук шагов — простой генератор через Web Audio API. - // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE. - this._audioCtx = null; - this._distanceSinceLastStep = 0; - this.STEP_DISTANCE_WALK = 1.6; - this.STEP_DISTANCE_SPRINT = 1.1; - - // Угол поворота модели — следует за направлением движения, а не yaw камеры. - // Когда игрок стоит — сохраняем последний угол. - this._modelYaw = 0; - this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с) - - this._listeners = []; - this._beforeRender = null; - - // === Stick к движущейся платформе === - // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его - // на дельту движения этого объекта (его позиция могла измениться). - this._lastGroundData = null; - this._lastGroundPos = null; - - // === Тач-режим (мобилки/планшеты) === - // Если true — pointer-lock не запрашивается, mouse-listener не активен, - // ввод управляется снаружи через setVirtualKey/addCameraDelta. - this._touchMode = false; - // Фактическая скорость поворота камеры от тача (рад/пиксель). - this.TOUCH_SENSITIVITY = 0.005; - } - - /** - * Включить тач-режим. Вызывать ДО start(), на тач-устройствах. - * В этом режиме: - * - pointer-lock НЕ запрашивается - * - mousemove игнорируется - * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры), - * но дополнительно работают setVirtualKey() / addCameraDelta(). - */ - setTouchMode(enabled) { - this._touchMode = !!enabled; - } - - /** - * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code: - * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space' - * Для шифта — отдельный параметр. - */ - setVirtualKey(code, pressed) { - if (pressed) this._codes.add(code); - else this._codes.delete(code); - } - - /** Программное нажатие/отпускание Shift (бег). */ - setVirtualShift(pressed) { - this._shift = !!pressed; - } - - /** - * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены). - * dx, dy — пиксели свайпа. - */ - addCameraDelta(dx, dy) { - this._yaw += dx * this.TOUCH_SENSITIVITY; - this._pitch += dy * this.TOUCH_SENSITIVITY; - const lim = Math.PI / 2 - 0.05; - if (this._pitch > lim) this._pitch = lim; - if (this._pitch < -lim) this._pitch = -lim; - } - - /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */ - triggerJump() { - this._codes.add('Space'); - // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить - // нажатие и инициировать прыжок (он проверяет onGround + Space). - setTimeout(() => this._codes.delete('Space'), 100); - } - - /** - * Аналоговый ввод движения для тач-джойстика. - * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры), - * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint). - * - * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться - * к дискретным клавишам, передай null. - */ - setAnalogMove(x, y) { - if (x === null || y === null) { - this._analogMove = null; - return; - } - if (!this._analogMove) this._analogMove = { x: 0, y: 0 }; - this._analogMove.x = x; - this._analogMove.y = y; - } - - setOnExitRequest(cb) { - this._onExitRequest = cb; - } - - /** Установить тип модели персонажа — должен быть вызван ДО start(). */ - setModelType(typeId) { - this._modelTypeId = typeId || 'character-a'; - } - - /** - * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07). - * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту, - * грузит новую модель (R15 или non-humanoid). Возвращает Promise. - * - * Используется из game.player.setSkin(slug). - */ - async reloadSkin(typeId) { - if (!this._active) return false; - const newType = typeId || 'character-a'; - if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин - // 1) Выгрузить текущую модель и связанные аниматоры. - try { - if (this._modelRoot) { this._modelRoot.dispose(false, true); } - } catch (e) { /* ignore */ } - this._modelRoot = null; - this._modelMeshes = []; - this._rightArmMeshes = []; - this._r15Skeleton = null; - this._r15Animator = null; - this._isR15 = false; - this._modelKind = 'r15'; - this._modelHipHeight = null; - this._nonHumanoidBox = null; - // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит). - this.HALF_W = 0.3; - this.HALF_H = 0.9; - this.HALF_D = 0.3; - this.HALF_H_NORMAL = 0.9; - this.EYE_HEIGHT = 0.7; - // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу. - this._pos.y += 0.5; - // 4) Загрузить новую модель. - this._modelTypeId = newType; - await this._loadPlayerModel(); - return !!this._modelRoot; - } - - /** - * Запустить режим игры. - * spawnPos — точка спавна. Если не указано — (0, 5, 0). - */ - async start(spawnPos = null) { - if (this._active) return; - this._active = true; - - if (spawnPos) { - this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z); - } else { - this._pos = new Vector3(0, 5 + this.HALF_H, 0); - } - this._vy = 0; - this._yaw = 0; - this._pitch = 0; - this._modelYaw = 0; - this._codes.clear(); - this._shift = false; - - // FPS-камера - const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene); - cam.minZ = 0.1; - cam.maxZ = 1000; - cam.fov = 1.05; - cam.inputs.clear(); - this.scene.activeCamera = cam; - this.camera = cam; - - this._setupInput(); - - // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит - // без меша (или появляется частично), а движение/колайдер уже - // активны. start() теперь async-функция — все её вызовы (`await`). - await this._loadPlayerModel(); - - // Render-loop hook - this._beforeRender = () => this._tick(); - this.scene.registerBeforeRender(this._beforeRender); - - // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу: - // - first / lockfirst — постоянный lock - // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления - // Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit). - // ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'. - const needLockAtStart = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (needLockAtStart) { - this._requestPointerLockSafe(); - } - // Применяем видимость курсора (по умолчанию виден в third). - this._applyCursorVisibility(); - } - - /** - * Установить курсор видимым/скрытым через CSS на canvas. - * Pointer-lock сам прячет курсор когда активен, но в third без lock - * мы можем скрыть курсор через `cursor:none` если разработчик - * выключил его через setMouseIconVisible(false). - */ - _applyCursorVisibility() { - if (!this.canvas) return; - const locked = (document.pointerLockElement === this.canvas); - // Если lock активен — курсор и так скрыт. Иначе зависит от настроек. - if (locked) return; - const show = this._mouseIconVisible && !this._shiftLock; - this.canvas.style.cursor = show ? '' : 'none'; - } - - /** - * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых - * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём - * pointerlockchange и пробуем снова один раз. - */ - /** - * Включить/выключить «UI-режим курсора». - * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается. - * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false). - */ - /** Колбэк изменения HP — ({hp, maxHp}). */ - setOnHpChange(cb) { this._onHpChange = cb; } - setOnDeath(cb) { this._onDeath = cb; } - - /** Нанести урон игроку (с учётом i-frames). */ - takeDamage(amount, source) { - if (this.hp <= 0) return; - const now = performance.now() / 1000; - if (now - this._lastDamageTime < this._invulnerabilityTime) return; - this._lastDamageTime = now; - this.hp = Math.max(0, this.hp - Math.max(0, amount)); - // Flash-эффект для UI (через onHpChange флаг damaged=true) - if (this._onHpChange) { - try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {} - } - // Звук «ой» - this._playHurtSound(); - if (this.hp === 0) { - // Эффект распада - this._spawnDeathDebris(); - // Прячем модель игрока - if (this._modelRoot) this._modelRoot.setEnabled(false); - if (this._onDeath) { - try { this._onDeath(); } catch (e) {} - } - } - } - - /** Распад на куски при смерти. */ - _spawnDeathDebris() { - if (!this._pos) return; - const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z; - const colors = [ - new Color3(0.95, 0.78, 0.6), // кожа - new Color3(0.7, 0.5, 0.4), - new Color3(0.4, 0.4, 0.7), // одежда - new Color3(0.3, 0.25, 0.2), - ]; - for (let i = 0; i < 10; i++) { - const size = 0.18 + Math.random() * 0.14; - const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene); - const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene); - mat.diffuseColor = colors[i % colors.length]; - mat.specularColor = new Color3(0, 0, 0); - cube.material = mat; - cube.position.set( - cx + (Math.random() - 0.5) * 0.5, - cy + Math.random() * 0.6, - cz + (Math.random() - 0.5) * 0.5 - ); - cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); - cube.isPickable = false; - cube.alwaysSelectAsActiveMesh = true; - const debris = { - mesh: cube, mat, - vx: (Math.random() - 0.5) * 5, - vy: 4 + Math.random() * 3, - vz: (Math.random() - 0.5) * 5, - rx: (Math.random() - 0.5) * 10, - ry: (Math.random() - 0.5) * 10, - rz: (Math.random() - 0.5) * 10, - age: 0, - life: 2.0, - }; - if (!this._debris) this._debris = []; - this._debris.push(debris); - } - } - - /** Тик debris — вызывается в _tick. */ - _tickDebris(dt) { - if (!this._debris || this._debris.length === 0) return; - const G = -10; - const next = []; - for (const d of this._debris) { - d.age += dt; - if (d.age >= d.life) { - try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} - continue; - } - d.vy += G * dt; - d.mesh.position.x += d.vx * dt; - d.mesh.position.y += d.vy * dt; - d.mesh.position.z += d.vz * dt; - if (d.mesh.position.y < 0.1) { - d.mesh.position.y = 0.1; - d.vy *= -0.4; - d.vx *= 0.6; - d.vz *= 0.6; - } - d.mesh.rotation.x += d.rx * dt; - d.mesh.rotation.y += d.ry * dt; - d.mesh.rotation.z += d.rz * dt; - const fadeStart = d.life - 0.5; - if (d.age > fadeStart) { - const k = 1 - (d.age - fadeStart) / 0.5; - d.mesh.visibility = Math.max(0, k); - } - next.push(d); - } - this._debris = next; - } - - /** Короткий звук «ой» когда получили урон. */ - _playHurtSound() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const t = ctx.currentTime; - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(220, t); - osc.frequency.exponentialRampToValueAtTime(80, t + 0.15); - const g = ctx.createGain(); - g.gain.setValueAtTime(0.15, t); - g.gain.exponentialRampToValueAtTime(0.001, t + 0.2); - osc.connect(g).connect(ctx.destination); - osc.start(t); - osc.stop(t + 0.22); - } catch (e) { /* ignore */ } - } - - /** Полное восстановление HP (например при респавне). */ - healFull() { - this.hp = this.maxHp; - this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна - // Возвращаем модель - if (this._modelRoot) this._modelRoot.setEnabled(true); - // Сбрасываем оставшиеся debris - if (this._debris) { - for (const d of this._debris) { - try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} - } - this._debris = []; - } - if (this._onHpChange) { - try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {} - } - } - - setUiCursorMode(enabled) { - this._uiCursorMode = !!enabled; - if (enabled) { - // Освобождаем мышь - if (document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) { /* ignore */ } - } - } else { - // Возвращаем lock — но только если мы реально активны - if (this._active) { - this._requestPointerLockSafe(); - } - } - } - isUiCursorMode() { return !!this._uiCursorMode; } - - /** - * Callback, который вызывается при движении мыши в UI-режиме. - * fn(x, y) — нормализованные координаты [0..1] относительно канваса. - * Используется для drag-механик (Дальгона и т.д.). - */ - setUiMouseMoveCallback(fn) { - this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null; - } - /** mousedown в UI-режиме. fn(x, y). */ - setUiMouseDownCallback(fn) { - this._uiMouseDownCb = (typeof fn === 'function') ? fn : null; - } - /** mouseup в UI-режиме. fn(x, y). */ - setUiMouseUpCallback(fn) { - this._uiMouseUpCb = (typeof fn === 'function') ? fn : null; - } - - _requestPointerLockSafe(retried = false) { - if (!this._active || !this.canvas?.requestPointerLock) return; - // На тач-устройствах pointer-lock не нужен — управление через touch-overlay - if (this._touchMode) return; - // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui')) - // — не захватываем мышь. - if (this._uiCursorMode) return; - // Если уже есть lock на этот canvas — нечего делать - if (document.pointerLockElement === this.canvas) return; - // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем - if (document.pointerLockElement && document.pointerLockElement !== this.canvas) { - if (retried) return; // только одна попытка повтора - const onChange = () => { - document.removeEventListener('pointerlockchange', onChange); - if (this._active) this._requestPointerLockSafe(true); - }; - document.addEventListener('pointerlockchange', onChange, { once: true }); - return; - } - requestAnimationFrame(() => { - if (!this._active) return; - try { - const p = this.canvas.requestPointerLock(); - // Promise-форма: ловим reject (SecurityError) и пробуем повтор - if (p && typeof p.catch === 'function') { - p.catch((err) => { - if (!this._active) return; - // SecurityError — попробуем ещё раз через кадр (один раз) - if (!retried && err && err.name === 'SecurityError') { - setTimeout(() => this._requestPointerLockSafe(true), 50); - } - }); - } - } catch (e) { /* legacy form, ignore */ } - }); - } - - /** - * Загрузить манифест R15-скинов (характеристики + overrides). - * Кешируется в this._skinManifest. Возвращает массив skins или []. - */ - async _loadSkinManifest() { - if (this._skinManifest) return this._skinManifest; - 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; - } - - /** - * Определить путь к GLB и overrides для текущего _modelTypeId. - * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста - * - иначе → старая Kenney-модель через getModelType() - * Возвращает { file, isR15, overrides } или null. - */ - async _resolveModelSource() { - const typeId = this._modelTypeId || 'character-a'; - if (typeId.startsWith('skin_')) { - const manifest = await this._loadSkinManifest(); - const entry = manifest.find((s) => s.id === typeId); - if (entry) { - // kind определяет систему анимации: - // 'r15' → R15-скелет (как раньше) - // 'non-humanoid-mesh' → single-mesh, процедурное покачивание - // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup - // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом). - const kind = entry.kind || 'r15'; - return { - file: '/kubikon-assets/' + entry.file, - isR15: kind === 'r15', - kind, - overrides: entry.overrides || {}, - scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null, - hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null, - rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, - }; - } - // нет в манифесте — пробуем прямой путь (старые R15-скины) - return { - file: `/kubikon-assets/characters/${typeId}/body.glb`, - isR15: true, - kind: 'r15', - overrides: {}, - }; - } - // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные - // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs. - if (typeId.startsWith('customskin:')) { - const slug = typeId.slice('customskin:'.length); - const list = this._scene3d?._skinsConfig?.customGlbs || []; - const meta = list.find(g => g && g.slug === slug) || null; - const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null; - if (url) { - return { - file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {}, - scaleManifest: meta?.scale ?? 1.5, - hipHeight: meta?.hipHeight ?? 0.4, - rotationYOffset: meta?.rotationYOffset ?? 0, - isDataUrl: true, - }; - } - return null; - } - const modelType = getModelType(typeId); - if (!modelType) return null; - return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; - } - - /** Загрузить GLB-модель персонажа и его анимации. */ - async _loadPlayerModel() { - const source = await this._resolveModelSource(); - if (!source) return; - if (!this._active) return; - - // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш - // ModelManager. Если бы мы использовали тот же AssetContainer - // что и зомби (через _loadPrototype), повторный - // instantiateModelsToScene давал меши с битыми материалами. - // Babylon HTTP-кэш всё равно убирает сетевые запросы. - let rootUrl, filename; - if (source.isDataUrl) { - // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' - // и filename=data:... с подсказкой расширения через ?name=. - rootUrl = ''; - filename = source.file; - } else { - const lastSlash = source.file.lastIndexOf('/'); - rootUrl = source.file.substring(0, lastSlash + 1); - filename = source.file.substring(lastSlash + 1); - } - let container; - try { - container = await SceneLoader.LoadAssetContainerAsync( - rootUrl, filename, this.scene, - null, source.isDataUrl ? '.glb' : undefined - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error('[PlayerController] failed to load model:', e); - return; - } - try { - if (!this._active) { - try { container.dispose(); } catch (e) {} - return; - } - // Создаём корневой узел и инстанцируем модель туда - const root = new TransformNode('playerModel', this.scene); - // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока). - // - R15-скины ('skin_*'): фиксированный 0.301 — модели - // нормализованы к 5.98 ед пайплайном auto_rig_bacon - // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах - // с торчащими волосами/плащами (как у bacon-hair). - // - Kenney-модели: старый 0.72. - // - overrides.scale_mult — per-skin множитель из манифеста. - const isNonHumanoid = source.kind === 'non-humanoid-mesh' - || source.kind === 'non-humanoid-rigged'; - let modelScale; - if (isNonHumanoid) { - // Non-humanoid: базовый размер берём из манифеста (scale), а если - // нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок). - modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0; - } else { - modelScale = source.isR15 ? 0.301 : this._modelScale; - const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; - modelScale *= scaleMult; - } - root.scaling = new Vector3(modelScale, modelScale, modelScale); - if (source.rotationYOffset) root.rotation.y = source.rotationYOffset; - const inst = container.instantiateModelsToScene( - (name) => `player_${name}`, - /*cloneAnimations*/ true, - { doNotInstantiate: false } - ); - for (const r of inst.rootNodes) { - r.parent = root; - } - this._modelRoot = root; - this._modelKind = source.kind || 'r15'; - // hipHeight: на сколько центр модели поднят от «низа ног». - // Используется и для позиционирования модели, и для камеры/AABB. - this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null; - - // Non-humanoid: нормализуем размер и опускаем модель на «ноги». - if (isNonHumanoid) { - this._setupNonHumanoidModel(root, modelScale, source); - } - - // === R15-скин: детекция скелета === - // R15-скины приходят с встроенным скелетом Mixamo. Babylon - // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер - // и, если скелет валидный, помечаем _isR15 + создаём аниматор. - this._isR15 = false; - this._r15Skeleton = null; - this._r15Animator = null; - this._skinOverrides = source.overrides || {}; - // eslint-disable-next-line no-console - console.log('[PlayerController] _loadPlayerModel: file=' + source.file - + ' isR15=' + source.isR15 - + ' inst.skeletons=' + ((inst.skeletons || []).length) - + ' rootNodes=' + (inst.rootNodes || []).length); - if (source.isR15) { - // Скелет ищем в нескольких местах: inst.skeletons (норма), - // container.skeletons (иногда не клонируется), на мешах - // модели (skeleton-property). Берём первый найденный. - let sk = (inst.skeletons && inst.skeletons[0]) || null; - if (!sk && container.skeletons && container.skeletons.length > 0) { - sk = container.skeletons[0]; - } - if (!sk) { - const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton); - if (meshWithSkel) sk = meshWithSkel.skeleton; - } - if (sk) { - // eslint-disable-next-line no-console - console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length - + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(',')); - const r15 = new R15Skeleton(sk); - if (r15.isValidR15()) { - this._r15Skeleton = r15; - this._isR15 = true; - this._r15Animator = new R15Animator(r15, this._skinOverrides); - // eslint-disable-next-line no-console - console.log('[PlayerController] R15-скин загружен:', - this._modelTypeId, '— костей:', r15.resolvedNames().length, - 'overrides:', JSON.stringify(this._skinOverrides)); - } else { - // eslint-disable-next-line no-console - console.warn('[PlayerController] R15-скин', this._modelTypeId, - '— скелет не прошёл валидацию. Зарезолвлено:', - r15.resolvedNames().join(','), - '| все кости скелета:', - (sk.bones || []).map(b => b.name).join(',')); - } - } else { - // eslint-disable-next-line no-console - console.warn('[PlayerController] R15-скин', this._modelTypeId, - '— нет скелета в glb'); - } - } - - // Собираем все mesh-чилдрены (для toggle visibility в 1st person) - this._modelMeshes = root.getChildMeshes(false); - // Игрок не должен ловить свой raycast → отключаем pickable - for (const m of this._modelMeshes) { - m.isPickable = false; - if (m.alwaysSelectAsActiveMesh !== undefined) { - m.alwaysSelectAsActiveMesh = true; - } - // Тени: персонаж принимает тени от мира и сам отбрасывает. - m.receiveShadows = true; - } - try { - if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') { - for (const m of this._modelMeshes) { - this._scene3d.addShadowCaster(m); - } - } - } catch (e) { /* ignore */ } - // У Kenney character имена `arm-left`/`arm-right` соответствуют - // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right). - // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его - // правая рука у нас слева на экране. - // - // Берём именно arm-right (его правую) — это та рука куда логично - // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4. - this._rightArmMeshes = []; - for (const m of this._modelMeshes) { - const n = (m.name || '').toLowerCase(); - // Берём именно «right» — настоящая правая рука персонажа - if (n === 'player_arm-right' || n.endsWith('arm-right') - || n.includes('right-arm') || n.includes('rightarm') - || n.includes('right-hand') || n.includes('hand-right')) { - this._rightArmMeshes.push(m); - break; - } - } - // Fallback: если по имени не нашли — берём левую по позиции (x<0) - if (this._rightArmMeshes.length === 0) { - let bestMesh = null; - let bestX = Infinity; - for (const m of this._modelMeshes) { - if (!m.position) continue; - const n = (m.name || '').toLowerCase(); - if (n.includes('leg') || n.includes('foot') || n.includes('head') - || n.includes('hat') || n.includes('torso') || n.includes('root')) continue; - const px = m.position.x; - const py = m.position.y; - if (py < 0.8 || py > 2.2) continue; - if (px >= -0.05) continue; // ищем X<0 - if (px < bestX) { bestX = px; bestMesh = m; } - } - if (bestMesh) this._rightArmMeshes = [bestMesh]; - } - const arm = this._rightArmMeshes[0]; - if (arm) { - this._rightArmX = arm.position.x; - this._rightArmY = arm.position.y; - this._rightArmZ = arm.position.z; - } - - // Анимации. - // R15-скины не содержат AnimationGroups (анимируются процедурно - // через R15Animator в _tick). Kenney-модели — наоборот, имеют - // встроенные AnimationGroups (idle/walk/sprint/jump). - this._animations = {}; - if (!this._isR15) { - const groups = inst.animationGroups || []; - for (const g of groups) { - const name = (g.name || '').toLowerCase(); - if (name.includes('idle')) this._animations.idle = g; - else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g; - else if (name.includes('walk')) this._animations.walk = g; - else if (name.includes('jump')) this._animations.jump = g; - g.stop(); - } - this._playAnim('idle'); - } - // Применяем текущий camera-mode (показать/скрыть модель) - this._applyCameraMode(); - } catch (e) { - // eslint-disable-next-line no-console - console.error('[PlayerController] failed to load model:', e); - } - } - - /** - * Настройка non-humanoid модели (животное/машина/еда): нормализация - * размера и опускание на «низ ног». В отличие от R15 (нормализованы - * пайплайном), эти модели произвольного размера, поэтому считаем bbox. - * - * Локальные координаты root: модель должна стоять так, чтобы её низ был - * на y=0 (там «ноги»). PlayerController позиционирует root в точке - * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю. - */ - _setupNonHumanoidModel(root, scaleApplied, source) { - try { - // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ - // применения scaling root'а. Babylon refreshBoundingInfo нужен после - // инстансинга. - const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0); - if (!meshes.length) return; - root.computeWorldMatrix(true); - let minY = Infinity, maxY = -Infinity, maxDim = 0; - let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; - for (const m of meshes) { - m.computeWorldMatrix(true); - // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного - // скелета/морфов; без него minimumWorld у инстансов часто нулевой - // или из исходной позы → центр считался неверно (баг пришельца/робота). - try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} } - const bi = m.getBoundingInfo(); - const bb = bi.boundingBox; - const lo = bb.minimumWorld, hi = bb.maximumWorld; - if (!lo || !hi) continue; - minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y); - minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x); - minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z); - } - if (!Number.isFinite(minX) || !Number.isFinite(minY)) return; - const h = maxY - minY; - const w = maxX - minX; - const d = maxZ - minZ; - maxDim = Math.max(h, w, d); - // === Центрирование модели через pivot-node === - // Многие Kenney-модели имеют origin НЕ в геометрическом центре - // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого - // origin (баг пришельца/робота). Ручной сдвиг детей с делением на - // scaleApplied неверен если у детей свой scale/rotation. Надёжно: - // вставляем промежуточный pivot между root и моделью и смещаем pivot - // на -localCenter (через инверсию world-матрицы root — точно при - // любом scale/rotation). - const worldCenter = new Vector3( - (minX + maxX) / 2, // центр X - minY, // низ Y (модель «садится» на ноги) - (minZ + maxZ) / 2 // центр Z - ); - // world-центр → локальные координаты root - const invRoot = root.getWorldMatrix().clone().invert(); - const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot); - const pivot = new TransformNode('playerModelPivot', this.scene); - pivot.parent = root; - pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z); - // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot. - for (const ch of root.getChildren().slice()) { - if (ch === pivot) continue; - ch.parent = pivot; - } - // Сохраняем размеры для настраиваемого AABB и камеры. - // hipHeight из манифеста — приоритетно; иначе берём низ модели. - this._nonHumanoidBox = { w, h, d }; - this._modelBaseHeight = h; - // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды). - // Ограничиваем разумными пределами чтобы не проваливаться/застревать. - this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2)); - this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2)); - const halfH = Math.max(0.3, Math.min(1.0, h / 2)); - this.HALF_H = halfH; - this.HALF_H_NORMAL = halfH; - this.EYE_HEIGHT = halfH * 0.7; - // eslint-disable-next-line no-console - console.log('[PlayerController] non-humanoid setup:', this._modelTypeId, - 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2), - 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2)); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[PlayerController] _setupNonHumanoidModel failed:', e); - } - } - - /** - * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать - * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при - * беге + наклон в воздухе. Вызывается каждый кадр из _tick. - * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel). - */ - _animateNonHumanoidMesh(dt) { - const root = this._modelRoot; - if (!root) return; - const t = (typeof performance !== 'undefined' && performance.now) - ? performance.now() / 1000 : Date.now() / 1000; - const speed = this._lastFrameSpeed || 0; - // Базовое вращение по yaw уже выставляет _tick (он крутит модель под - // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt - // поверх — храним их в отдельных полях, чтобы _tick их не перетёр. - let bobY = 0, tiltX = 0; - if (!this._isGrounded) { - tiltX = 0.2; // в воздухе — нос вверх - } else if (speed > 0.1) { - const bobFreq = 8 * Math.min(2, speed / 4); - bobY = Math.sin(t * bobFreq) * 0.06; - tiltX = Math.min(speed * 0.04, 0.13); - } else { - bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое - } - // Применяем поверх позиции, которую _tick уже выставил в root.position.y. - root.position.y += bobY; - // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом. - root.rotation.x = tiltX; - } - - /** AABB игрока пересекает хотя бы один блок-воду. */ - _isInWater() { - const bm = this._scene3d?.blockManager; - if (!bm) return false; - // FAST PATH: если на сцене нет водных блоков — точно не в воде. - // Большинство карт (зомби-остров, любые «суховые») — без воды, - // и тройной цикл ниже бесполезно тратит время каждый кадр. - if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; - const px = this._pos.x, py = this._pos.y, pz = this._pos.z; - const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D; - // Проверяем клетки которые AABB перекрывает - const gxMin = Math.floor(px - hw + 0.5); - const gxMax = Math.floor(px + hw + 0.5); - const gzMin = Math.floor(pz - hd + 0.5); - const gzMax = Math.floor(pz + hd + 0.5); - const gyMin = Math.floor(py - hh); - const gyMax = Math.floor(py + hh); - for (let gx = gxMin; gx <= gxMax; gx++) { - for (let gy = gyMin; gy <= gyMax; gy++) { - for (let gz = gzMin; gz <= gzMax; gz++) { - const m = bm.blocks.get(`${gx},${gy},${gz}`); - if (m?.metadata?.isWater) return true; - } - } - } - return false; - } - - /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */ - _isSubmerged() { - const bm = this._scene3d?.blockManager; - if (!bm) return false; - // FAST PATH: нет воды на сцене — не утопаем. - if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; - // Проверяем «голову» — точку чуть ниже верха AABB - const headY = this._pos.y + this.HALF_H - 0.1; - const gx = Math.round(this._pos.x); - const gy = Math.floor(headY); - const gz = Math.round(this._pos.z); - const m = bm.blocks.get(`${gx},${gy},${gz}`); - return !!m?.metadata?.isWater; - } - - /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */ - _playFootstep() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - - const now = ctx.currentTime; - const duration = 0.08; - - // Источник — короткий шумовой буфер - const sampleRate = ctx.sampleRate; - const length = Math.floor(sampleRate * duration); - const buffer = ctx.createBuffer(1, length, sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < length; i++) { - data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум - } - const src = ctx.createBufferSource(); - src.buffer = buffer; - - // Lowpass для тяжёлого «тук» вместо высокого «шшш» - const lowpass = ctx.createBiquadFilter(); - lowpass.type = 'lowpass'; - lowpass.frequency.value = 350; - lowpass.Q.value = 1.5; - - // Envelope (быстрая атака, быстрое затухание) - const gain = ctx.createGain(); - gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(0.7, now + 0.005); - gain.gain.exponentialRampToValueAtTime(0.001, now + duration); - - src.connect(lowpass).connect(gain).connect(ctx.destination); - src.start(now); - src.stop(now + duration); - } catch (e) { - // ignore — звук не критичен - } - } - - /** - * Звук прыжка — мягкий «boing» из двух слоёв: - * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами» - * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма» - * Гораздо приятнее старого квадратного восходящего тона. - */ - /** - * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation. - * Работает только для R15-скинов (Kenney-модели эмоций не имеют). - */ - playEmote(name) { - if (this._isR15 && this._r15Animator) { - return this._r15Animator.playEmote(name); - } - return false; - } - - /** Прервать текущую эмоцию персонажа. */ - stopEmote() { - if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote(); - } - - _playJumpSound() { - // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок - // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся. - if (typeof this._onJump === 'function') { - try { this._onJump(); } catch (e) { /* ignore */ } - } - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const now = ctx.currentTime; - const out = ctx.destination; - - // Слой 1: низкий thump - const thumpDur = 0.07; - const thumpOsc = ctx.createOscillator(); - thumpOsc.type = 'sine'; - thumpOsc.frequency.setValueAtTime(110, now); - thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur); - const thumpGain = ctx.createGain(); - thumpGain.gain.setValueAtTime(0, now); - thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005); - thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur); - thumpOsc.connect(thumpGain).connect(out); - thumpOsc.start(now); - thumpOsc.stop(now + thumpDur + 0.02); - - // Слой 2: «boing» — pitch-down sine - const boingDur = 0.18; - const boingOsc = ctx.createOscillator(); - boingOsc.type = 'sine'; - boingOsc.frequency.setValueAtTime(720, now + 0.005); - boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur); - const boingGain = ctx.createGain(); - boingGain.gain.setValueAtTime(0, now + 0.005); - boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02); - boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur); - // Лёгкое vibrato чтобы было «живее» - const lfo = ctx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.value = 14; - const lfoGain = ctx.createGain(); - lfoGain.gain.value = 18; // ±18 Hz - lfo.connect(lfoGain).connect(boingOsc.frequency); - boingOsc.connect(boingGain).connect(out); - boingOsc.start(now + 0.005); - lfo.start(now + 0.005); - boingOsc.stop(now + 0.005 + boingDur + 0.02); - lfo.stop(now + 0.005 + boingDur + 0.02); - } catch (e) { /* ignore */ } - } - - /** Звук «бульк» при входе/выходе из воды. */ - _playSplashSound() { - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const now = ctx.currentTime; - const duration = 0.35; - - // Шум с быстро падающим highpass — звук «всплеска» - const length = Math.floor(ctx.sampleRate * duration); - const buf = ctx.createBuffer(1, length, ctx.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < length; i++) { - data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2; - } - const src = ctx.createBufferSource(); - src.buffer = buf; - - const bp = ctx.createBiquadFilter(); - bp.type = 'bandpass'; - bp.frequency.setValueAtTime(2000, now); - bp.frequency.exponentialRampToValueAtTime(400, now + duration); - bp.Q.value = 1.5; - - const g = ctx.createGain(); - g.gain.setValueAtTime(0, now); - g.gain.linearRampToValueAtTime(0.6, now + 0.01); - g.gain.exponentialRampToValueAtTime(0.001, now + duration); - - src.connect(bp).connect(g).connect(ctx.destination); - src.start(now); - src.stop(now + duration); - } catch (e) { /* ignore */ } - } - - _playAnim(name) { - if (this._currentAnim === name) return; - const target = this._animations[name]; - if (!target) { - // если нужной нет — пробуем idle как fallback - if (name !== 'idle' && this._animations.idle) { - return this._playAnim('idle'); - } - return; - } - // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ === - // Помогает дебажить вибрацию ног на склонах: если в логе видно - // частое мерцание walk↔jump или idle↔walk — значит onGround или - // isMoving скачет каждые несколько кадров. - const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false }; - console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} ` - + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`); - // Стопим текущую - if (this._currentAnim && this._animations[this._currentAnim]) { - this._animations[this._currentAnim].stop(); - } - target.start(/*loop*/ true, /*speed*/ 1); - this._currentAnim = name; - } - - /** - * Остановить режим игры. Освобождает все ресурсы. - */ - stop() { - if (!this._active) return; - this._active = false; - - if (this._beforeRender) { - this.scene.unregisterBeforeRender(this._beforeRender); - this._beforeRender = null; - } - for (const { target, type, fn, opts } of this._listeners) { - target.removeEventListener(type, fn, opts); - } - this._listeners = []; - - if (document.pointerLockElement === this.canvas) { - document.exitPointerLock(); - } - - // Останавливаем все анимации - for (const g of Object.values(this._animations)) { - try { g.stop(); } catch (e) { /* ignore */ } - } - this._animations = {}; - this._currentAnim = null; - - // Удаляем якорь оружия - if (this._weaponAnchor) { - try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ } - this._weaponAnchor = null; - } - // Сбрасываем все override'ы вращения - if (this._meshRotationOverrides) { - this._meshRotationOverrides.clear(); - } - // Сброс R15-состояния - if (this._r15BoneOverrides) this._r15BoneOverrides.clear(); - this._r15Animator = null; - this._r15Skeleton = null; - this._isR15 = false; - - // Удаляем модель - if (this._modelRoot) { - for (const m of this._modelMeshes) { - try { m.dispose(); } catch (e) { /* ignore */ } - } - try { this._modelRoot.dispose(); } catch (e) { /* ignore */ } - this._modelRoot = null; - this._modelMeshes = []; - } - - if (this.camera) { - this.camera.dispose(); - this.camera = null; - } - - if (this._audioCtx) { - try { this._audioCtx.close(); } catch (e) { /* ignore */ } - this._audioCtx = null; - } - } - - isActive() { - return this._active; - } - - // === ВНУТРЕННЕЕ === - - /** Позиция камеры в мире (зависит от режима first/third/front). */ - _computeCameraPos() { - // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up - // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем» - // высоту чтобы камера не дёргалась рывком при step-up. - const visY = this._pos.y - this._stepUpVisualOffset; - if (this._cameraMode === 'first') { - return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z); - } - if (this._cameraMode === 'sideview') { - // Кубикон Dash: камера сбоку, фиксированный yaw на куб. - // Игрок движется по +X, камера в -Z от него (смотрит на +Z). - // С этого ракурса +X на экране = вправо (как в Geometry Dash). - // Лёгкое смещение camera по X влево от куба — игрок в левой - // трети кадра, впереди видно больше уровня. - return new Vector3( - this._pos.x - 1.5, - visY + SIDEVIEW_HEIGHT, - this._pos.z - SIDEVIEW_DIST - ); - } - // forward — направление «куда смотрит игрок» с учётом yaw и pitch - const cosP = Math.cos(this._pitch); - const fx = Math.sin(this._yaw) * cosP; - const fy = -Math.sin(this._pitch); - const fz = Math.cos(this._yaw) * cosP; - const dist = this._thirdDistance; - - // Точка «глаз» игрока — отсюда пускаем луч к запланированной - // позиции камеры и сокращаем дистанцию если упёрлись в стену. - const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET; - - if (this._cameraMode === 'third') { - const desired = new Vector3( - this._pos.x - fx * dist, - eyeY - fy * dist, - this._pos.z - fz * dist - ); - return this._clampCameraToWorld( - this._pos.x, eyeY, this._pos.z, desired - ); - } - // 'front' — спереди игрока, направлена назад (на лицо) - const desiredFront = new Vector3( - this._pos.x + fx * dist, - eyeY + fy * dist, - this._pos.z + fz * dist - ); - return this._clampCameraToWorld( - this._pos.x, eyeY, this._pos.z, desiredFront - ); - } - - /** - * Не позволяет камере «проходить» сквозь стены/блоки/примитивы. - * Пускает луч от глаз игрока до запланированной позиции камеры. - * Если на пути есть препятствие — возвращает точку чуть ближе - * (hit.distance - PADDING), чтобы камера прижалась к стене. - * - * Игнорирует: - * - меши без metadata (вспомогательная техника редактора), - * - триггеры (canCollide===false), - * - саму модель игрока, - * - debris/particles. - */ - _clampCameraToWorld(ex, ey, ez, desired) { - if (!this.scene) return desired; - // Вектор от глаз до желаемой камеры. - const dx = desired.x - ex; - const dy = desired.y - ey; - const dz = desired.z - ez; - const len = Math.sqrt(dx * dx + dy * dy + dz * dz); - if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт - const dir = new Vector3(dx / len, dy / len, dz / len); - const origin = new Vector3(ex, ey, ez); - const ray = new Ray(origin, dir, len); - - const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась» - const playerRoot = this._modelRoot; - - const pickPred = (mesh) => { - if (!mesh) return false; - if (!mesh.isEnabled || !mesh.isEnabled()) return false; - // Прозрачно-визуальные и technical meshes — пропускаем. - if (mesh.isPickable === false) return false; - const md = mesh.metadata || {}; - // Триггеры/невидимки/скриптовые маркеры — не блокируют. - if (md.canCollide === false) return false; - if (md._isTriggerHelper) return false; - // Модель игрока (камера не должна цепляться за собственный меш). - if (playerRoot) { - let n = mesh; - while (n) { - if (n === playerRoot) return false; - n = n.parent; - } - } - // Жидкости (вода/лава) — не блокируют камеру. - if (md._liquidProxy) return false; - return true; - }; - - let hit = null; - try { - hit = this.scene.pickWithRay(ray, pickPred); - } catch (e) { - return desired; - } - if (!hit || !hit.hit || hit.distance >= len - 0.01) { - return desired; - } - // Сокращаем дистанцию. - const clampedLen = Math.max(0.3, hit.distance - PADDING); - return new Vector3( - ex + dir.x * clampedLen, - ey + dir.y * clampedLen, - ez + dir.z * clampedLen - ); - } - - // ===== Управление камерой из скрипта (Фаза 5.7) ===== - - /** Установить угол обзора камеры (FOV) в градусах. */ - setCameraFov(degrees) { - if (!this.camera) return; - const d = Number(degrees); - if (!Number.isFinite(d) || d < 10 || d > 130) return; - this.camera.fov = d * Math.PI / 180; - } - - /** - * Привязать камеру к объекту — она смотрит на него. - * getTarget — функция, возвращающая {x,y,z} цели. - * opts: { distance, height } — отступ камеры от цели. - */ - cameraFocusOn(getTarget, opts = {}) { - if (typeof getTarget !== 'function') return; - this._cameraOverride = { - mode: 'focus', - getTarget, - distance: Number.isFinite(opts.distance) ? opts.distance : 8, - height: Number.isFinite(opts.height) ? opts.height : 4, - }; - } - - /** - * Катсцена — плавный пролёт камеры по точкам. - * points — массив {x,y,z} позиций камеры. - * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию). - * segDuration — секунд на отрезок между точками. - * onDone — колбэк по завершении. - */ - cameraCutscene(points, lookAt, segDuration, onDone) { - if (!Array.isArray(points) || points.length < 2) return; - this._cameraOverride = { - mode: 'cutscene', - points, - lookAt: Array.isArray(lookAt) ? lookAt : [], - segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2, - t: 0, // время от начала - seg: 0, // текущий отрезок - onDone: typeof onDone === 'function' ? onDone : null, - }; - } - - /** Вернуть камеру под управление игрока. */ - cameraReset() { - this._cameraOverride = null; - } - - /** Применить активный режим камеры скрипта (вызывается в _tick). */ - _applyCameraOverride(dt) { - const o = this._cameraOverride; - if (!o || !this.camera) return; - if (o.mode === 'focus') { - const t = o.getTarget(); - if (!t) return; - // Камера позади-сверху цели, смотрит на неё. - this.camera.position.set(t.x, t.y + o.height, t.z + o.distance); - this.camera.setTarget(new Vector3(t.x, t.y, t.z)); - } else if (o.mode === 'cutscene') { - // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB) - // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается» - // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт - // ровно свою длительность независимо от лагов. - o.t += Math.min(dt, 1 / 30); - const segCount = o.points.length - 1; - // Прогресс по текущему отрезку [0..1]. - let local = o.t / o.segDuration; - let seg = Math.floor(o.t / o.segDuration); - if (seg >= segCount) { - // Катсцена завершена — встаём на последнюю точку. - const last = o.points[o.points.length - 1]; - this.camera.position.set(last.x, last.y, last.z); - const lookLast = o.lookAt[o.lookAt.length - 1]; - if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z)); - const cb = o.onDone; - this._cameraOverride = null; - if (cb) { try { cb(); } catch (e) { /* ignore */ } } - return; - } - local = local - seg; // дробная часть = прогресс отрезка - // Сглаживание (ease-in-out) — плавный пролёт. - const k = local < 0.5 - ? 2 * local * local - : 1 - Math.pow(-2 * local + 2, 2) / 2; - const a = o.points[seg], b = o.points[seg + 1]; - this.camera.position.set( - a.x + (b.x - a.x) * k, - a.y + (b.y - a.y) * k, - a.z + (b.z - a.z) * k, - ); - // Точка взгляда — интерполяция между соседними lookAt. - const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg]; - if (la && lb) { - this.camera.setTarget(new Vector3( - la.x + (lb.x - la.x) * k, - la.y + (lb.y - la.y) * k, - la.z + (lb.z - la.z) * k, - )); - } - } - } - - /** - * Применить текущий режим камеры: - * - В 1st person скрываем модель игрока (видим только сцену) - * - В 3rd person и front показываем - */ - _applyCameraMode() { - const visible = this._cameraMode !== 'first'; - for (const m of this._modelMeshes) { - m.setEnabled(visible); - } - // Сообщаем оружию что режим камеры сменился — чтобы перепарентить - // view-model (камера в 1-st, модель игрока в 3-rd). - if (this._scene3d?.weapons?.onCameraModeChange) { - this._scene3d.weapons.onCameraModeChange(this._cameraMode); - } - } - - /** - * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели. - * - * Установить override-rotation для именованного меша поверх анимации. - * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion, - * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет - * наши углы Эйлера. - * - * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...). - * Без префикса 'player_'. - * rotation — Vector3 углов Эйлера (rad). null → снять override. - * - * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты). - */ - setMeshRotationOverride(meshName, rotation) { - // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт - // этот метод для позы/замаха — переадресуем на override кости. - // R15Animator каждый кадр ставит rest+анимацию; override кости - // применяется поверх в _applyR15BoneOverrides() после update(). - if (this._isR15) { - if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map(); - // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость. - const lower = (meshName || '').toLowerCase(); - const logical = (lower.includes('right')) ? 'RightUpperArm' - : (lower.includes('left')) ? 'LeftUpperArm' - : 'RightUpperArm'; - if (rotation == null) { - this._r15BoneOverrides.delete(logical); - } else { - this._r15BoneOverrides.set(logical, - rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); - } - return; - } - if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map(); - if (rotation == null) { - this._meshRotationOverrides.delete(meshName); - } else { - this._meshRotationOverrides.set(meshName, - rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); - } - } - - /** - * Применить override-повороты костей R15 поверх процедурной анимации. - * Вызывается после R15Animator.update(). Используется WeaponSystem - * для позы руки с оружием / melee-замаха. - */ - _applyR15BoneOverrides() { - const map = this._r15BoneOverrides; - if (!map || map.size === 0 || !this._r15Skeleton) return; - for (const [logical, rot] of map.entries()) { - const bone = this._r15Skeleton.resolveBone(logical); - if (!bone) continue; - // Override задаётся как абсолютный локальный поворот кости - // (Эйлер). Перекрывает то, что поставил аниматор этим кадром. - const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z); - bone.setRotationQuaternion(q, Space.LOCAL); - } - } - - /** Получить меш модели по короткому имени (без префикса 'player_'). */ - getModelMesh(meshName) { - if (!this._modelMeshes) return null; - const target = `player_${meshName}`; - return this._modelMeshes.find(m => m.name === target) || null; - } - - /** - * Применить все активные override'ы. Вызывается каждый кадр в _tick - * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после - * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его - * каждый кадр и пишем в .rotation. - */ - _applyMeshRotationOverrides() { - const map = this._meshRotationOverrides; - if (!map || map.size === 0) return; - for (const [meshName, rot] of map.entries()) { - const mesh = this.getModelMesh(meshName); - if (!mesh) continue; - if (mesh.rotationQuaternion) { - mesh.rotationQuaternion = null; - } - mesh.rotation.x = rot.x; - mesh.rotation.y = rot.y; - mesh.rotation.z = rot.z; - } - } - - /** - * Включить/выключить «позу с оружием». - * Делает 2 вещи независимо: - * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney). - * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает - * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0, - * и дуло автоматически смотрит вперёд персонажа. - * - * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять - * ориентацию повёрнутого меша руки. - */ - _updateExtendedArm(hasWeapon) { - // === R15-скин: якорь оружия на кости RightHand === - // У R15 нет меша-руки — есть кость. Якорь привязываем к кости - // через attachToBone: оружие следует за рукой при анимации. - if (this._isR15 && this._r15Skeleton) { - const showWeapon = hasWeapon && this._cameraMode !== 'first'; - if (showWeapon && !this._weaponAnchor) { - const handBone = this._r15Skeleton.resolveBone('RightHand'); - const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0]; - if (handBone && skinMesh) { - this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); - // attachToBone — якорь следует за костью каждый кадр. - this._weaponAnchor.attachToBone(handBone, skinMesh); - // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье. - this._weaponAnchor.position.set(0, 0.05, 0.1); - } - } - if (this._weaponAnchor) { - this._weaponAnchor.setEnabled(showWeapon); - } - return; - } - - const armMesh = this._rightArmMeshes?.[0]; - if (!armMesh) return; - const meshName = (armMesh.name || '').replace(/^player_/, ''); - const showWeapon = hasWeapon && this._cameraMode !== 'first'; - - // 1) Поза руки через override - if (showWeapon) { - this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0)); - } else { - this.setMeshRotationOverride(meshName, null); - } - - // 2) ChIstый якорь для оружия — TransformNode на плече персонажа, - // ориентация совпадает с _modelRoot (без поворотов). - if (showWeapon && !this._weaponAnchor && this._modelRoot) { - this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); - this._weaponAnchor.parent = this._modelRoot; - // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша. - // Плечо: y = origin + 0.7 (выше). - // X: сдвигаем чуть наружу (ещё правее). - const sx = -(armMesh.position?.x ?? -0.4) + 0.15; - const sy = (armMesh.position?.y ?? 1.1) + 0.7; - const sz = (armMesh.position?.z ?? 0) + 0.95; - this._weaponAnchor.position.set(sx, sy, sz); - } - if (this._weaponAnchor) { - this._weaponAnchor.setEnabled(showWeapon); - } - } - - getWeaponAnchor() { return this._weaponAnchor || null; } - - /** Цикл first ↔ third. */ - _toggleCameraMode() { - const idx = CAMERA_MODES.indexOf(this._cameraMode); - this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; - this._applyCameraMode(); - // При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock) - if (this._cameraMode === 'first') { - this._requestPointerLockSafe(); - } else if (!this._shiftLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - this._applyCursorVisibility?.(); - } - - /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус - * всегда лицом к камере, камера через плечо). - */ - setShiftLock(on) { - this._shiftLock = !!on; - if (this._shiftLock) { - // Запросить pointer-lock — курсор в центре - this._requestPointerLockSafe(); - } else { - // Снять lock если он есть и нет других причин держать (first/sideview) - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' - ); - if (!needPermLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - } - this._applyCursorVisibility?.(); - } - isShiftLock() { return !!this._shiftLock; } - - /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc). - * Не блокирует Esc/Tab/Enter (нужны для GUI). - * Также сбрасывает накопленные клавиши чтобы движение остановилось. */ - setInputBlocked(blocked) { - this._inputBlocked = !!blocked; - if (this._inputBlocked) { - try { this._codes?.clear(); } catch (e) {} - this._shift = false; - // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры» - try { - if (document.pointerLockElement === this.canvas) document.exitPointerLock(); - } catch (e) {} - } - } - isInputBlocked() { return !!this._inputBlocked; } - - /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */ - setCameraFrozen(frozen) { - this._cameraFrozen = !!frozen; - } - isCameraFrozen() { return !!this._cameraFrozen; } - - /** Задача 04: снимок состояния камеры — для восстановления после модала. */ - captureCameraState() { - return { - yaw: this._yaw, - pitch: this._pitch, - cameraMode: this._cameraMode, - thirdDistance: this._thirdDistance, - fov: this.scene?.activeCamera?.fov, - playerPos: this._pos ? { - x: this._pos.x, y: this._pos.y, z: this._pos.z - } : null, - }; - } - - /** Задача 04: восстановить состояние камеры из снимка. */ - restoreCameraState(s) { - if (!s) return; - if (Number.isFinite(s.yaw)) this._yaw = s.yaw; - if (Number.isFinite(s.pitch)) this._pitch = s.pitch; - if (s.cameraMode) { - this._cameraMode = s.cameraMode; - try { this._applyCameraMode?.(); } catch (e) {} - } - if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance; - if (Number.isFinite(s.fov) && this.scene?.activeCamera) { - this.scene.activeCamera.fov = s.fov; - } - } - - /** Задача 04: камера-фокус на reference (cube/npc/cam-target). - * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}. - * Использует уже существующий механизм camera.focus в GameRuntime, но - * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель, - * и зум на distance. */ - focusOnTarget(ref, opts) { - opts = opts || {}; - const distance = Number.isFinite(opts.distance) ? opts.distance : 8; - const height = Number.isFinite(opts.height) ? opts.height : 3; - const fov = Number.isFinite(opts.fov) ? opts.fov : null; - let target = null; - if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) { - target = ref; - } else { - const m = this._resolveTargetMesh(ref); - if (m) { - const p = m.getAbsolutePosition?.() || m.position; - target = { x: p.x, y: p.y, z: p.z }; - } - } - if (!target) return; - // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target - // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch. - if (!this._pos) return; - const dx = target.x - this._pos.x; - const dz = target.z - this._pos.z; - const dy = target.y - this._pos.y; - const horiz = Math.hypot(dx, dz); - this._yaw = Math.atan2(dx, dz); - this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz))); - this._thirdDistance = distance; - if (this._cameraMode !== 'third') { - this._cameraMode = 'third'; - try { this._applyCameraMode?.(); } catch (e) {} - } - if (fov && this.scene?.activeCamera) { - this.scene.activeCamera.fov = fov * Math.PI / 180; - } - } - - _resolveTargetMesh(ref) { - if (!ref) return null; - if (ref.getScene && typeof ref.getScene === 'function') return ref; - const sc = this._scene3d || this.scene3d; - const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); - if (!idStr || !sc) return null; - const tries = [ - () => sc.primitiveManager?.getMesh?.(idStr), - () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0], - () => sc.scene?.getMeshByName?.(idStr), - () => sc.npcManager?.getMeshes?.(idStr)?.[0], - ]; - for (const fn of tries) { - try { const r = fn(); if (r) return r; } catch (e) {} - } - return null; - } - - /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */ - setCameraZoom(distance) { - const d = Number(distance); - if (!Number.isFinite(d)) return; - this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, - Math.min(this.THIRD_DISTANCE_MAX, d)); - // Авто-переход third↔first если пересекли порог - if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD - && this._cameraMode === 'third') { - this._cameraMode = 'first'; - this._applyCameraMode?.(); - this._requestPointerLockSafe(); - } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD - && this._cameraMode === 'first' && !this._lockFirstPerson) { - this._cameraMode = 'third'; - this._applyCameraMode?.(); - if (!this._shiftLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - } - } - /** Установить границы зума колеса. */ - setCameraZoomLimits(min, max) { - const mn = Number(min), mx = Number(max); - if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn; - if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx; - // Перекламп текущей дистанции - this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, - Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance)); - } - /** Поведение мыши: default / lockcenter / lockcurrent. - * default — свободный курсор (стандартный browser cursor). - * lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y). - * lockcurrent — pointer-lock, но без скрытия (визуально как default, - * реально движение отслеживается через movementX/Y). - */ - setMouseBehavior(mode) { - if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return; - this._mouseBehavior = mode; - if (mode === 'default') { - // Снимаем lock если ничто другое не требует его - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (!needPermLock && document.pointerLockElement === this.canvas) { - try { document.exitPointerLock(); } catch (e) {} - } - } else { - this._requestPointerLockSafe(); - } - this._applyCursorVisibility?.(); - } - /** Видимость курсора (для third без lock). */ - setMouseIconVisible(visible) { - this._mouseIconVisible = !!visible; - this._applyCursorVisibility?.(); - } - - _setupInput() { - const canvas = this.canvas; - - const onCanvasClick = () => { - // В UI-режиме клик не перехватывает мышь. - if (this._uiCursorMode) return; - if (!this._active) return; - // Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор — - // курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем - // ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/ - // sideview/shiftLock), и только если по какой-то причине lock сняли - // (например, юзер нажал Esc в first-режиме — надо вернуть lock). - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (!needPermLock) return; - if (document.pointerLockElement !== canvas) { - try { - const p = canvas.requestPointerLock?.(); - if (p && typeof p.catch === 'function') p.catch(() => {}); - } catch (e) { /* ignore */ } - } - }; - canvas.addEventListener('click', onCanvasClick); - - // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру === - // Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру. - // Отпустил → курсор вернулся на ту же позицию (браузер сам ставит). - const onCanvasMouseDownGlobal = (e) => { - if (!this._active || this._uiCursorMode) return; - if (e.button !== 2) return; // только ПКМ - // В режимах с постоянным lock'ом ПКМ ничего не делает - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (needPermLock) return; - // Запрашиваем lock — теперь mouseMove будет крутить камеру. - 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 onWindowMouseUpGlobal = (e) => { - if (e.button !== 2) return; - if (!this._rmbHeld) return; - this._rmbHeld = false; - // Отпускаем lock только если он был включён нами для orbit-камеры - // (т.е. сейчас НЕ режим с постоянным lock). - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (needPermLock) return; - if (document.pointerLockElement === canvas) { - try { document.exitPointerLock(); } catch (err) { /* ignore */ } - } - }; - canvas.addEventListener('mousedown', onCanvasMouseDownGlobal); - window.addEventListener('mouseup', onWindowMouseUpGlobal); - // Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger). - canvas.addEventListener('contextmenu', (e) => { - if (this._active) e.preventDefault(); - }); - - // === UI-режим: mousedown / mouseup → callback (для drag-игр) === - const onCanvasMouseDown = (e) => { - if (!this._uiCursorMode) return; - if (typeof this._uiMouseDownCb !== 'function') return; - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { - try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ } - } - }; - const onCanvasMouseUp = (e) => { - if (!this._uiCursorMode) return; - if (typeof this._uiMouseUpCb !== 'function') return; - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ } - }; - canvas.addEventListener('mousedown', onCanvasMouseDown); - // mouseup ловим на document — мышь могла уйти за пределы канваса - document.addEventListener('mouseup', onCanvasMouseUp); - - const onMouseMove = (e) => { - // === UI-режим: транслируем нормализованные [0..1] координаты === - // подписчику (Worker через GameRuntime). Используется для drag-игр - // типа Дальгона. - if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') { - const rect = canvas.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - const y = (e.clientY - rect.top) / rect.height; - // Кидаем только если внутри канваса - if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { - try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ } - } - } - if (document.pointerLockElement !== canvas) return; - // Кубикон Dash: в sideview мышь не вращает камеру. - if (this._cameraMode === 'sideview') return; - // Задача 04: модал с freezeCamera — мышь не вращает. - if (this._cameraFrozen) return; - this._yaw += e.movementX * this.MOUSE_SENSITIVITY; - this._pitch += e.movementY * this.MOUSE_SENSITIVITY; - const lim = Math.PI / 2 - 0.05; - if (this._pitch > lim) this._pitch = lim; - if (this._pitch < -lim) this._pitch = -lim; - }; - document.addEventListener('mousemove', onMouseMove); - - // Колесо: zoom в third + авто-переключение third ↔ first. - // Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person - // (с pointer-lock). Колесо наружу из first → возврат в third. - const onWheel = (e) => { - if (!this._active) return; - if (this._cameraMode === 'sideview') return; - // Задача 04: модал с freezeCamera — колесо не зумит. - if (this._cameraFrozen) { e.preventDefault(); return; } - // В first-режиме колесо вверх НЕ работает (если lockfirst), вниз - // выходит обратно в third (если zoomable first, не lockfirst). - if (this._cameraMode === 'first') { - if (this._lockFirstPerson) { e.preventDefault(); return; } - if (e.deltaY > 0) { - // Колесо вниз → отдалить → переход в third - this._cameraMode = 'third'; - this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5; - this._applyCameraMode?.(); - // Снять pointer-lock — в third без shift-lock курсор виден - if (!this._shiftLock && document.pointerLockElement === canvas) { - try { document.exitPointerLock(); } catch (err) {} - } - } - e.preventDefault(); - return; - } - if (this._cameraMode !== 'third') return; - // Шаг зума — пропорционален текущей дистанции (экспоненциальный фил) - const step = Math.max(0.3, this._thirdDistance * 0.15); - this._thirdDistance += Math.sign(e.deltaY) * step; - 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) - if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) { - this._cameraMode = 'first'; - this._applyCameraMode?.(); - // Запросить pointer-lock — first всегда залочен - if (!this._shiftLock && document.pointerLockElement !== canvas) { - this._requestPointerLockSafe(); - } - } - e.preventDefault(); - }; - canvas.addEventListener('wheel', onWheel, { passive: false }); - - let wasLocked = false; - const onPointerLockChange = () => { - const locked = document.pointerLockElement === canvas; - 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) - this._rmbHeld = false; - if (this._uiCursorMode) { - this._applyCursorVisibility(); - return; - } - const needPermLock = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._cameraMode === 'sideview' || - this._shiftLock - ); - if (needPermLock) { - // Был режим с постоянным lock'ом и его сняли → Esc → выход - if (this._onExitRequest) this._onExitRequest(); - } else { - // Third-person: пользователь просто отпустил ПКМ. Курсор - // возвращается там же где был — это нормально, остаёмся в Play. - this._applyCursorVisibility(); - } - } - }; - document.addEventListener('pointerlockchange', onPointerLockChange); - - const isTypingTarget = (target) => { - if (!target) return false; - const tag = (target.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; - return !!target.isContentEditable; - }; - const onKeyDown = (e) => { - if (!this._active) return; - if (isTypingTarget(e.target)) return; - // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest - // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc - // в third (без pointer-lock) сразу выходил из Play. - if (e.code === 'Escape') { - if (this._onExitRequest) { - this._onExitRequest(); - return; - } - } - // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.), - // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик), - // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах). - if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') { - // Глотаем preventDefault только для игровых клавиш - if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); - return; - } - this._codes.add(e.code); - if (e.shiftKey) this._shift = true; - // C — переключение first/third. Отключаем в GD-режиме (автобег > 0) - // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview. - if (e.code === 'KeyC') { - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - if (!inGdMode) this._toggleCameraMode(); - } - // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег, - // поэтому переназначено на L). Курсор центрируется, корпус всегда - // лицом к камере, камера через плечо. - if (e.code === 'KeyL') { - this.setShiftLock(!this._shiftLock); - } - // B — встроенный магазин скинов (задача 07). Открывается только если - // включён в проекте (scene.skins.shopVisible). Toggle. - if (e.code === 'KeyB' && !this._inputBlocked) { - try { this._scene3d?.toggleSkinShop?.(); } catch (err) {} - } - // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) - if (e.code === 'Tab') { - e.preventDefault(); - this.setUiCursorMode(!this._uiCursorMode); - } - if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { - e.preventDefault(); - } - // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус), - // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек. - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) { - e.preventDefault(); - } - }; - const onKeyUp = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.delete(e.code); - if (!e.shiftKey) this._shift = false; - }; - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - - const onBlur = () => { - this._codes.clear(); - this._shift = false; - }; - window.addEventListener('blur', onBlur); - - this._listeners = [ - { target: canvas, type: 'click', fn: onCanvasClick }, - { target: canvas, type: 'wheel', fn: onWheel, - opts: { passive: false } }, - { target: document, type: 'mousemove', fn: onMouseMove }, - { target: document, type: 'pointerlockchange', fn: onPointerLockChange }, - { target: window, type: 'keydown', fn: onKeyDown }, - { target: window, type: 'keyup', fn: onKeyUp }, - { target: window, type: 'blur', fn: onBlur }, - ]; - } - - _tick() { - // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая - // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж - // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1 - // (max 10 кадров/сек физики). Этого хватает чтобы движение было - // плавным даже на 5 FPS — просто чуть рывками. - let dt = this.scene.getEngine().getDeltaTime() / 1000; - if (dt <= 0) return; - if (dt > 0.1) dt = 0.1; - - // === Присед: по Ctrl на десктопе, или через мобильную кнопку - // (которая шлёт keydown 'ControlLeft'). C — НЕ используется - // (это смена вида в Babylon). - // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику - // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship). - const inGdMode = (this._autoRunSpeed || 0) > 0 - || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; - const wantCrouch = !inGdMode && this._codes - && (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); - if (wantCrouch && !this._crouching) { - this._crouching = true; - // сдвигаем центр капсулы вниз — низ ног остаётся на земле - const dH = this.HALF_H_CROUCH - this.HALF_H; - this.HALF_H = this.HALF_H_CROUCH; - if (this._pos) this._pos.y += dH; - } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { - this._crouching = false; - const dH = this.HALF_H_NORMAL - this.HALF_H; - this.HALF_H = this.HALF_H_NORMAL; - if (this._pos) this._pos.y += dH; - } - - // === Горизонтальное движение === - const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); - const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); - const isSprinting = this._shift; - const speedMult = isSprinting ? this.SPRINT_MULT : 1; - const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; - - let moveX = 0, moveZ = 0; - // c (codes) используется ниже для прыжка/Space — объявляем здесь, - // чтобы был доступен после if/else движения. - const c = this._codes; - const am = this._analogMove; - // === Кубикон Dash: авто-движение по +X, ввод заблокирован === - // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме - // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются. - if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) { - moveX = this._autoRunSpeed * dt; - moveZ = 0; - } else - // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z === - // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит - // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО — - // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал - // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос - // делает скрипт через player.teleport по X. - if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) { - moveX = 0; - moveZ = this._autoRunSpeed * dt; - } else - // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами: - // позволяет двигаться в любом направлении плавно, не только в 8 секторов. - if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) { - // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*). - // *На практике мы передаём sprint через setVirtualShift. - const mag = Math.min(1, Math.hypot(am.x, am.y)); - // Нормализуем направление, скорость = mag * speed - const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y)); - const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y)); - const v = mag * speed; - // y=1 → вперёд (forward), x=1 → вправо (right) - moveX = forward.x * dirY * v + right.x * dirX * v; - moveZ = forward.z * dirY * v + right.z * dirX * v; - } else { - if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; } - if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; } - if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; } - if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; } - } - - const isMoving = (moveX !== 0 || moveZ !== 0); - - // === ЛЁД: инерция движения === - // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость - // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр) - // с коэффициентом ускорения, и затухает с (1 - friction*dt*8). - if (this._iceFriction > 0.001) { - const fric = Math.min(1, this._iceFriction); - // Чем выше friction, тем медленнее набираем скорость и тем дольше - // затухает после отпускания клавиш. - const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45 - const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1 - // Целевая скорость = moveX/moveZ (уже содержит dt) - // Прибавляем разницу с ускорением - this._iceVelX += (moveX - this._iceVelX) * accel; - this._iceVelZ += (moveZ - this._iceVelZ) * accel; - if (!isMoving) { - this._iceVelX *= decay; - this._iceVelZ *= decay; - if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0; - if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0; - } - moveX = this._iceVelX; - moveZ = this._iceVelZ; - } else { - // Сбрасываем накопленную скорость когда лёд выкл - this._iceVelX = 0; - this._iceVelZ = 0; - } - - // === Плавание (если AABB пересекает блок-воду) === - const inWater = this._isInWater(); - const submerged = this._isSubmerged(); - if (inWater) { - // В воде движемся в 2 раза медленнее - moveX *= 0.5; - moveZ *= 0.5; - } - - // === Вертикальное === - if (inWater) { - // Плавание: лёгкая гравитация + плавучесть к поверхности - const buoyancy = submerged ? 6 : 0; - const swimGravity = -3; - this._vy += (buoyancy + swimGravity) * dt; - this._vy *= Math.max(0, 1 - 3 * dt); - if (this._codes.has('Space')) this._vy += 14 * dt; - if (this._vy > 4) this._vy = 4; - if (this._vy < -4) this._vy = -4; - } else { - // Кубикон Dash: умеренная усиленная гравитация для коротких - // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с - // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2, - // не слишком улетает по X (~4-5м за прыжок). - // Variable-jump (отпускание Space обрезает vy) НЕ используется — - // в авто-беге игрок не контролирует длительность нажатия. - // - // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ - // к потолку. Кап-скорости тоже инвертируется. - const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0; - const userGravityMul = this._gravityMul || 1; - const gDir = this._gravityDir || 1; - - if (this._waveMode) { - // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|). - // Гравитация полностью игнорируется — линейное движение по диагонали. - const speed = Math.max(1, this._autoRunSpeed || 8); - this._vy = this._jumpHeld ? speed : -speed; - } else { - this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt; - - // SHIP-режим: при удержании Space даём импульс ВВЕРХ - // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс - // делает плавный полёт. - if (this._shipMode && this._jumpHeld) { - // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался, - // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36) - const SHIP_THRUST = 80; // м/с² против гравитации - this._vy += SHIP_THRUST * gDir * dt; - } - // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем - // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается. - if (this._robotMode && this._robotBoostLeft > 0) { - if (c.has('Space')) { - // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно. - // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену). - this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt; - this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt); - } else { - this._robotBoostLeft = 0; - } - } - - // Cap: ±50 в любом направлении (для Ship — мягче, ±25) - const vyCap = this._shipMode ? 25 : 50; - if (this._vy < -vyCap) this._vy = -vyCap; - if (this._vy > vyCap) this._vy = vyCap; - } - } - - const beforeX = this._pos.x, beforeZ = this._pos.z; - - // === STICK к движущейся платформе === - // Если в прошлом кадре стояли на примитиве/модели, и она двигается — - // двигаем игрока вместе с ней (по дельте позиции платформы за кадр). - if (this._lastGroundData && this._lastGroundPos) { - const gd = this._lastGroundData; - // Текущая позиция платформы — берём из live-data (она обновляется - // movingPlatforms-скриптом через scene.move). - const curX = gd.x, curY = gd.y, curZ = gd.z; - const dPlatX = curX - this._lastGroundPos.x; - const dPlatY = curY - this._lastGroundPos.y; - const dPlatZ = curZ - this._lastGroundPos.z; - // Применяем дельту только если она разумная (защита от телепорта - // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.) - if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) { - this._pos.x += dPlatX; - this._pos.y += dPlatY; - this._pos.z += dPlatZ; - } - } - - // PERF-METRICS: замер физики игрока - const _pt0 = performance.now(); - const result = this.physics.moveAABB( - this._pos, this.HALF_W, this.HALF_H, this.HALF_D, - moveX, this._vy * dt, moveZ - ); - const _bs = this._scene3d || this.scene3d; - if (_bs && _bs._perfMetrics) { - _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; - _bs._perfMetrics.physics_count++; - } - this._pos.set(result.x, result.y, result.z); - if (result.hitY) this._vy = 0; - // Surface-follow на smooth-terrain прижал нас к склону — гравитация - // больше не нужна на этом кадре, иначе будет вибрация от соревнования - // (гравитация тянет вниз → следующий кадр surface поднимает обратно). - if (result.surfaceFollowed) this._vy = 0; - - // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы - // визуально плавно подняться. Физика уже сделала телепорт вверх, - // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже - // в кадровой логике (раз за tick). - if (result.steppedUpBy && result.steppedUpBy > 0) { - this._stepUpVisualOffset += result.steppedUpBy; - // Кэп — не более 1.2м, иначе при множественных степах подряд - // оффсет может стать гигантским и аватар «уйдёт под землю». - if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2; - } - // Спадание оффсета. dt — реальное время тика (секунды). - if (this._stepUpVisualOffset > 0) { - this._stepUpVisualOffset -= this._stepUpDecay * dt; - if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0; - } - - // Запоминаем «на чём стоим» для следующего кадра - if (result.onGround && result.groundData?.data) { - this._lastGroundData = result.groundData.data; - this._lastGroundPos = { - x: result.groundData.data.x, - y: result.groundData.data.y, - z: result.groundData.data.z, - }; - } else { - this._lastGroundData = null; - this._lastGroundPos = null; - } - - // === Авто-вылезание на берег из воды === - // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться — - // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание». - if (inWater && isMoving && (result.hitX || result.hitZ)) { - this._vy = Math.max(this._vy, 5); - } - - // Респавн если игрок упал в пустоту (за пределы baseplate) - if (this._pos.y < -30) { - const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; - this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z); - this._vy = 0; - } - - // === Push unanchored объектов при пересечении === - // Скорость игрока в этом кадре (для направления толчка). - // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0, - // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD) - // и forward камеры — DynamicsManager выберет лучшее направление. - const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt); - const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt); - const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ); - const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal); - // Берём "желаемое" движение если оно больше реального (игрок упёрся, - // но WASD нажаты — значит "пытается толкать"). - const useDesired = desiredSpeed > realSpeed + 0.5; - const pushVx = useDesired ? moveX / dt : playerVxReal; - const pushVz = useDesired ? moveZ / dt : playerVzReal; - if (this._scene3d?.dynamics?.isEnabled?.()) { - this._scene3d.dynamics.applyPushFromPlayer( - this._pos.x, this._pos.y, this._pos.z, - this.HALF_W, this.HALF_H, this.HALF_D, - pushVx, pushVz, - forward.x, forward.z, - playerVxReal, playerVzReal - ); - } - - // === Чекпоинты — обновить точку спавна при касании === - if (this.physics?.getOverlappingPrimitives) { - const overlaps = this.physics.getOverlappingPrimitives( - this._pos.x, this._pos.y, this._pos.z, - this.HALF_W, this.HALF_H, this.HALF_D - ); - for (const data of overlaps) { - if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) { - this._activatedCheckpoints.add(data.id); - if (this._scene3d) { - // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта - // чтобы при респавне игрок не попал внутрь чекпоинта. - this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z); - } - this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь») - } - } - } - - // Шаги — копим пройденную горизонтальную дистанцию когда onGround - if (result.onGround && isMoving) { - const dxReal = this._pos.x - beforeX; - const dzReal = this._pos.z - beforeZ; - this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal); - const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK; - if (this._distanceSinceLastStep >= stepThreshold) { - this._distanceSinceLastStep = 0; - this._playFootstep(); - } - } else { - // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки - // не воспроизвёлся слишком рано. - this._distanceSinceLastStep = 0; - } - - // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть. - // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже - // края, понимает что упал в яму, бесит. С коянот-окном — прощает. - // - // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом», - // используем result.onCeiling вместо onGround. - const gDir = this._gravityDir || 1; - const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false); - if (effGround) { - this._coyoteLeft = 0.12; - } else if (this._coyoteLeft > 0) { - this._coyoteLeft -= dt; - } - const canJump = effGround || this._coyoteLeft > 0; - // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде. - // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации). - // В Ship-режиме обычный jump-импульс отключён — корабль управляется - // только удержанием Space (см. блок _shipMode выше). _jumpHeld - // обновляем чтобы остальная логика (release, double-jump) работала. - if (this._waveMode) { - // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block). - // _jumpHeld для синхронизации с состоянием Space. - this._jumpHeld = c.has('Space'); - } else - if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { - if (!this._jumpHeld) { - // Robot — стартовый импульс полный (как куб) для тапа достаточный, - // boost-фаза 0.45с удлиняет подъём при удержании Space. - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; - this._playJumpSound(); - this._jumpHeld = true; - this._coyoteLeft = 0; - // Robot: запускаем boost-фазу на 0.45с - if (this._robotMode) { - this._robotBoostLeft = 0.45; - } - } - } else if (this._shipMode && c.has('Space')) { - this._jumpHeld = true; - } else if (this._ufoMode && c.has('Space') && !inWater) { - // UFO: каждый отдельный тап = микропрыжок (даже в воздухе). - // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата. - if (!this._jumpHeld) { - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85; - this._playJumpSound(); - this._jumpHeld = true; - } - } - // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка - if (effGround) this._doubleJumpUsed = false; - // Двойной прыжок: в воздухе и Space нажат отдельно (после release). - if (!effGround && !inWater && c.has('Space') - && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) { - this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; - this._playJumpSound(); - this._doubleJumpUsed = true; - this._jumpHeld = true; - } - if (!c.has('Space')) this._jumpHeld = false; - - // Звук всплеска при входе/выходе из воды - if (this._wasInWater !== inWater) { - this._playSplashSound(); - this._wasInWater = inWater; - } - - // Состояние игрока для game.player.state ('ground'|'air'|'water'). - this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air'); - // Хук game.onPlayerLand — был в воздухе, коснулся земли. - if (effGround && this._wasOnGround === false && !inWater) { - if (typeof this._onLand === 'function') { - try { this._onLand(); } catch (e) { /* ignore */ } - } - } - this._wasOnGround = effGround; - - // === Камера === - this.camera.position = this._computeCameraPos(); - // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную. - if (this._cameraOverride) { - this._applyCameraOverride(dt); - } - // Camera shake — небольшое случайное смещение по X/Y, затухает. - if (this._cameraShakeLeft > 0) { - this._cameraShakeLeft -= dt; - const t = Math.max(0, this._cameraShakeLeft); - const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка - this.camera.position.x += (Math.random() - 0.5) * amp; - this.camera.position.y += (Math.random() - 0.5) * amp; - } - // Скрипт-управление камерой (cutscene/focus) уже выставило И - // позицию, И направление взгляда через setTarget. НЕ трогаем - // camera.rotation — иначе setTarget затирается и катсцена - // «смотрит прямо» вместо вращения по точкам lookAt. - if (!this._cameraOverride) { - if (this._cameraMode === 'front') { - // Камера смотрит назад на игрока — yaw на 180°, pitch инверт. - this.camera.rotation.x = -this._pitch; - this.camera.rotation.y = this._yaw + Math.PI; - } else if (this._cameraMode === 'sideview') { - // Sideview: камера в -Z от игрока, смотрит на +Z. - this.camera.rotation.x = 0; - this.camera.rotation.y = 0; - this.camera.rotation.z = 0; - } else { - this.camera.rotation.x = this._pitch; - this.camera.rotation.y = this._yaw; - } - if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0; - } - - // === Модель игрока === - if (this._modelRoot) { - // === Поза: в воде персонаж лежит горизонтально (плавает) === - // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция. - const targetSwimTilt = inWater ? Math.PI / 2 : 0; - const swimTilt = this._swimTilt ?? 0; - const tiltStep = 4 * dt; // 4 рад/с скорость наклона - let nextTilt = swimTilt; - if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) { - nextTilt = targetSwimTilt; - } else { - nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep; - } - this._swimTilt = nextTilt; - - // В воде наклон 90° кладёт модель горизонтально. yLift поднимает - // root к центру AABB. Также при наклоне корень оказывается в позиции - // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root - // на длину модели вперёд по yaw чтобы голова была впереди. - const tiltFrac = nextTilt / (Math.PI / 2); // 0..1 - const yLift = inWater ? this.HALF_H * tiltFrac : 0; - const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела - const fwdShift = inWater ? bodyLen * tiltFrac : 0; - const fx = Math.sin(this._modelYaw); - const fz = Math.cos(this._modelYaw); - this._modelRoot.position.set( - this._pos.x + fx * fwdShift, - this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset, - this._pos.z + fz * fwdShift - ); - - // Поворот модели: - // - на суше: направление РЕАЛЬНОГО движения (как было). - // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто - // двигает тело вбок без вращения, как на суше при first-person. - if (inWater) { - const targetYaw = this._yaw; - let diff = targetYaw - this._modelYaw; - while (diff > Math.PI) diff -= Math.PI * 2; - while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt * 2; - if (Math.abs(diff) <= maxStep) { - this._modelYaw = targetYaw; - } else { - this._modelYaw += Math.sign(diff) * maxStep; - } - } else { - // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно - // следует за yaw камеры (AutoRotate привязан к камере). - // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения. - const followCamera = ( - this._cameraMode === 'first' || - this._cameraMode === 'lockfirst' || - this._shiftLock - ); - if (followCamera) { - const targetYaw = this._yaw; - let diff = targetYaw - this._modelYaw; - while (diff > Math.PI) diff -= Math.PI * 2; - while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе - if (Math.abs(diff) <= maxStep) { - this._modelYaw = targetYaw; - } else { - this._modelYaw += Math.sign(diff) * maxStep; - } - } else { - const dxReal = this._pos.x - beforeX; - const dzReal = this._pos.z - beforeZ; - const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; - if (movedHorizontal) { - const targetYaw = Math.atan2(dxReal, dzReal); - let diff = targetYaw - this._modelYaw; - while (diff > Math.PI) diff -= Math.PI * 2; - while (diff < -Math.PI) diff += Math.PI * 2; - const maxStep = this.MODEL_TURN_SPEED * dt; - if (Math.abs(diff) <= maxStep) { - this._modelYaw = targetYaw; - } else { - this._modelYaw += Math.sign(diff) * maxStep; - } - } - } - } - // Применяем yaw + swim-tilt. - // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит - // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift). - this._modelRoot.rotation.y = this._modelYaw; - this._modelRoot.rotation.x = nextTilt; - // В воде также добавляем лёгкое покачивание по Z (как волна тела) - if (inWater) { - const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15; - this._modelRoot.rotation.z = wobble; - } else if (this._cameraMode === 'sideview') { - // Кубикон Dash: в воздухе куб крутится назад (по часовой если - // смотреть с -Z), на земле плавно возвращается в 0. - // Скорость подобрана так чтобы между прыжками куб успевал - // совершить ~1 оборот. - const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с - if (!result.onGround) { - this._dashSpinAngle -= SPIN_SPEED * dt; - } else { - // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0) - const TAU = Math.PI * 2; - const target = Math.round(this._dashSpinAngle / TAU) * TAU; - const diff = target - this._dashSpinAngle; - const snapStep = SPIN_SPEED * 1.5 * dt; - if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target; - else this._dashSpinAngle += Math.sign(diff) * snapStep; - } - this._modelRoot.rotation.z = this._dashSpinAngle; - } else { - this._modelRoot.rotation.z = 0; - } - // Поза с оружием — обновляем флаг каждый кадр (на случай смены) - const hasWeapon = !!this._scene3d?.weapons?._equipped; - this._updateExtendedArm(hasWeapon); - // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций. - // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем - // и пишем свои углы Эйлера. Это ключевой момент: вызывается - // каждый кадр, поэтому override переживает анимацию. - this._applyMeshRotationOverrides(); - } - - // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)). - // Применяем каждый кадр — на случай если меши только что асинхронно - // загрузились, либо _applyCameraMode перезаписал enabled=true. - if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) { - for (let i = 0; i < this._modelMeshes.length; i++) { - const m = this._modelMeshes[i]; - if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { - try { m.setEnabled(false); } catch (e) {} - } - } - } - - // Тик распадающихся кусков игрока (после смерти) - this._tickDebris(dt); - - // === Анимации === - // Снимок скорости/опоры для процедурной анимации non-humanoid скинов. - this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1); - this._isGrounded = !!result.onGround; - - // Non-humanoid single-mesh скин: костей нет — анимируем процедурно - // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них. - if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) { - this._animateNonHumanoidMesh(dt); - return; - } - - // R15-скин: процедурный аниматор (нет glTF AnimationGroups). - // Состояния: idle/walk/run/jump/fall. sprint → run. - if (this._isR15 && this._r15Animator) { - let r15State; - if (!result.onGround) { - // vy > 0 — вверх (jump-поза с поджатыми ногами), - // vy < 0 — вниз (fall, лёгкий наклон корпуса). - r15State = (this._vy > 0.5) ? 'jump' : 'fall'; - } else if (inWater) { - r15State = isMoving ? 'walk' : 'idle'; - } else if (isMoving) { - r15State = isSprinting ? 'run' : 'walk'; - } else { - r15State = 'idle'; - } - this._r15Animator.setState(r15State); - this._r15Animator.update(dt); - // Override костей поверх анимации (поза руки с оружием/замах). - this._applyR15BoneOverrides(); - return; // R15 не использует AnimationGroups-путь ниже - } - - let nextAnim; - if (inWater) { - // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе - nextAnim = isMoving ? 'walk' : 'idle'; - } else if (!result.onGround) { - nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle'); - } else if (isMoving) { - nextAnim = isSprinting ? 'sprint' : 'walk'; - } else { - nextAnim = 'idle'; - } - // Снимок состояния для лога в _playAnim - this._lastAnimDebug = { - vy: this._vy, - og: !!result.onGround, - sf: !!result.surfaceFollowed, - mv: !!isMoving, - }; - // === Периодический trace состояния (раз в 30 кадров ~0.5с) === - // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация - // не меняется — полезно для разбора "вибрации". - if (!this._animTraceCnt) this._animTraceCnt = 0; - this._animTraceCnt++; - if (this._animTraceCnt >= 30) { - this._animTraceCnt = 0; - const d = this._lastAnimDebug; - console.log(`[AnimTrace] anim=${this._currentAnim} ` - + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`); - } - this._playAnim(nextAnim); - } -} +/** + * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения). + * + * Камера: + * 1st person — камера в позиции глаз игрока, модель невидима. + * 3rd person — камера сзади-сверху, модель видна. + * Переключение: клавиша C циклит first ↔ third. + * Колесо мыши в third-person — меняет дистанцию (zoom). + * + * Модель игрока: + * Грузим GLB через ModelManager (тот же что для editor-моделей). + * Корневой TransformNode хранит position/rotation модели. + * В 3rd person модель видна и крутится в направлении движения. + * Анимации (idle/walk/sprint) переключаются по скорости/спринту. + * + * Управление: + * W/A/S/D / стрелки — движение в горизонтальной плоскости + * Space — прыжок + * Shift — спринт (×1.7) + * C — переключить камеру 1st ↔ 3rd + * Esc — выйти из игры (через pointer-lock release) + */ +import { + Vector3, UniversalCamera, SceneLoader, TransformNode, + MeshBuilder, StandardMaterial, Color3, + Quaternion, Space, Ray, +} from '@babylonjs/core'; +import { getModelType } from './ModelTypes'; +import { R15Skeleton } from './R15Skeleton'; +import { R15Animator } from './R15Animator'; + +// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). +// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа. +const CAMERA_MODES = ['third', 'first', 'front']; +// Для режима 'sideview' (Кубикон Dash): +// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z) +// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб +// и ~12м препятствий впереди влезали в кадр на 16:9. +const SIDEVIEW_DIST = 14; +const SIDEVIEW_HEIGHT = 2.5; + +export class PlayerController { + constructor(scene, canvas, physics, scene3d = null) { + this.scene = scene; + this.canvas = canvas; + this.physics = physics; + this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint) + this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали + + // AABB + this.HALF_W = 0.3; + this.HALF_H = 0.9; + this.HALF_D = 0.3; + this.EYE_HEIGHT = 0.7; // глаза от центра AABB + + this.WALK_SPEED = 4.5; + this.SPRINT_MULT = 1.7; + this.JUMP_VELOCITY = 8; + this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне) + this._speedMul = 1; // множитель скорости передвижения + this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная) + this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт) + this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе + this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз) + this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump) + this._robotBoostLeft = 0; // оставшееся время boost-фазы (с) + // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с. + // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся. + this._autoRunSpeed = 0; + // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе). + // В sideview-камере при прыжке куб эффектно крутится — визитка GD. + this._dashSpinAngle = 0; + // Camera shake: amplitude + remaining time. Применяется в _tick после + // _computeCameraPos. Используется через game.camera.shake(amp, dur). + this._cameraShakeAmp = 0; + this._cameraShakeLeft = 0; + // Управление камерой из скрипта (Фаза 5.7). null = обычная камера + // игрока. Иначе объект режима: + // { mode:'focus', getTarget } — следить за объектом; + // { mode:'cutscene', points, durations, ... } — пролёт по точкам. + this._cameraOverride = null; + // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть. + // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround. + this._coyoteLeft = 0; + this._doubleJumpEnabled = false; + this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте» + // Кубикон Dash: направление гравитации. +1 = нормально (вниз), + // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD). + // Применяется только в sideview-режиме. Влияет на: + // - vy += GRAVITY * gravityDir * dt + // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз) + // - "onGround" определение: hitY + vy*gravityDir < 0 + // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте). + this._gravityDir = 1; + // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка, + // 1 = полностью скользко (инерция держится бесконечно). Реалистичный + // лёд = ~0.85. Настраивается через game.player.setIceFriction(value). + this._iceFriction = 0; + this._iceVelX = 0; + this._iceVelZ = 0; + // Присед — уменьшает высоту AABB. Включается через + // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45. + this._crouching = false; + this.HALF_H_NORMAL = 0.9; + this.HALF_H_CROUCH = 0.45; + this.GRAVITY = -22; + this.MOUSE_SENSITIVITY = 0.0025; + + // 3rd person camera (Roblox-style: 0.5 .. 32) + this.THIRD_DISTANCE_MIN = 0.5; + this.THIRD_DISTANCE_MAX = 32; + this.THIRD_DISTANCE_DEFAULT = 5; + this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока + // Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5) + this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; + // Lockfirst-режим: нельзя выйти из first-person зумом наружу + this._lockFirstPerson = false; + // Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере + // (включается клавишей L по дефолту, или game.player.setShiftLock(true)) + this._shiftLock = false; + // Видимость курсора по умолчанию (game.input.setMouseIconVisible) + this._mouseIconVisible = true; + // Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован) + // / 'lockcurrent' (зафиксирован на текущей позиции) + this._mouseBehavior = 'default'; + // Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third) + this._rmbHeld = false; + + this.camera = null; + this._active = false; + this._onExitRequest = null; + + // Состояние игрока + this._pos = new Vector3(0, 5, 0); + this._vy = 0; + this._yaw = 0; + this._pitch = 0; + + // Камера. Дефолт — первое лицо (как в большинстве игр). + this._cameraMode = 'third'; + this._thirdDistance = this.THIRD_DISTANCE_DEFAULT; + + // Ввод + this._codes = new Set(); + this._shift = false; + + // Auto-step visual smoothing. Когда PhysicsAABB телепортирует + // игрока вверх на уступ (steppedUpBy), физически он уже наверху, + // но мы интерполируем рендер: показываем модель и камеру со сдвигом + // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0. + // Получается визуально как плавный «полупрыжок» без рывка. + this._stepUpVisualOffset = 0; + // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс. + this._stepUpDecay = 4.5; + + // Модель игрока (грузится в start) + // Дефолт — R15-скин bacon-hair (классический Roblox-вид). + this._modelTypeId = 'skin_bacon-hair'; + this._modelRoot = null; + this._modelMeshes = []; + // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет + // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР + // в _tick (после анимаций), чтобы скрин оставался скрытым даже после + // асинхронной загрузки модели или после _applyCameraMode. + this._skinVisibleScripted = true; + this._animations = {}; + this._currentAnim = null; + // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока). + // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72. + this._modelScale = 0.72; + + // === R15-скин (bacon-hair и др.) === + // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций). + // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из + // characters//body.glb, детектируем скелет, анимируем + // процедурно через R15Animator (см. _loadPlayerModel / _tick). + this._isR15 = false; // флаг: загружен валидный R15-скелет + this._r15Skeleton = null; // R15Skeleton — резолвер костей + this._r15Animator = null; // R15Animator — процедурные анимации + this._skinManifest = null; // кеш skins_manifest.json + this._skinOverrides = {}; // overrides текущего скина + + // === Жизни игрока === + this.maxHp = 100; + this.hp = 100; + this._lastDamageTime = 0; + this._invulnerabilityTime = 0.5; // 500мс i-frames после удара + this._onHpChange = null; + this._onDeath = null; + + // Звук шагов — простой генератор через Web Audio API. + // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE. + this._audioCtx = null; + this._distanceSinceLastStep = 0; + this.STEP_DISTANCE_WALK = 1.6; + this.STEP_DISTANCE_SPRINT = 1.1; + + // Угол поворота модели — следует за направлением движения, а не yaw камеры. + // Когда игрок стоит — сохраняем последний угол. + this._modelYaw = 0; + this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с) + + this._listeners = []; + this._beforeRender = null; + + // === Stick к движущейся платформе === + // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его + // на дельту движения этого объекта (его позиция могла измениться). + this._lastGroundData = null; + this._lastGroundPos = null; + + // === Тач-режим (мобилки/планшеты) === + // Если true — pointer-lock не запрашивается, mouse-listener не активен, + // ввод управляется снаружи через setVirtualKey/addCameraDelta. + this._touchMode = false; + // Фактическая скорость поворота камеры от тача (рад/пиксель). + this.TOUCH_SENSITIVITY = 0.005; + } + + /** + * Включить тач-режим. Вызывать ДО start(), на тач-устройствах. + * В этом режиме: + * - pointer-lock НЕ запрашивается + * - mousemove игнорируется + * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры), + * но дополнительно работают setVirtualKey() / addCameraDelta(). + */ + setTouchMode(enabled) { + this._touchMode = !!enabled; + } + + /** + * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code: + * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space' + * Для шифта — отдельный параметр. + */ + setVirtualKey(code, pressed) { + if (pressed) this._codes.add(code); + else this._codes.delete(code); + } + + /** Программное нажатие/отпускание Shift (бег). */ + setVirtualShift(pressed) { + this._shift = !!pressed; + } + + /** + * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены). + * dx, dy — пиксели свайпа. + */ + addCameraDelta(dx, dy) { + this._yaw += dx * this.TOUCH_SENSITIVITY; + this._pitch += dy * this.TOUCH_SENSITIVITY; + const lim = Math.PI / 2 - 0.05; + if (this._pitch > lim) this._pitch = lim; + if (this._pitch < -lim) this._pitch = -lim; + } + + /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */ + triggerJump() { + this._codes.add('Space'); + // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить + // нажатие и инициировать прыжок (он проверяет onGround + Space). + setTimeout(() => this._codes.delete('Space'), 100); + } + + /** + * Аналоговый ввод движения для тач-джойстика. + * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры), + * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint). + * + * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться + * к дискретным клавишам, передай null. + */ + setAnalogMove(x, y) { + if (x === null || y === null) { + this._analogMove = null; + return; + } + if (!this._analogMove) this._analogMove = { x: 0, y: 0 }; + this._analogMove.x = x; + this._analogMove.y = y; + } + + setOnExitRequest(cb) { + this._onExitRequest = cb; + } + + /** Установить тип модели персонажа — должен быть вызван ДО start(). */ + setModelType(typeId) { + this._modelTypeId = typeId || 'character-a'; + } + + /** + * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07). + * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту, + * грузит новую модель (R15 или non-humanoid). Возвращает Promise. + * + * Используется из game.player.setSkin(slug). + */ + async reloadSkin(typeId) { + if (!this._active) return false; + const newType = typeId || 'character-a'; + if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин + // 1) Выгрузить текущую модель и связанные аниматоры. + try { + if (this._modelRoot) { this._modelRoot.dispose(false, true); } + } catch (e) { /* ignore */ } + this._modelRoot = null; + this._modelMeshes = []; + this._rightArmMeshes = []; + this._r15Skeleton = null; + this._r15Animator = null; + this._isR15 = false; + this._modelKind = 'r15'; + this._modelHipHeight = null; + this._nonHumanoidBox = null; + // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит). + this.HALF_W = 0.3; + this.HALF_H = 0.9; + this.HALF_D = 0.3; + this.HALF_H_NORMAL = 0.9; + this.EYE_HEIGHT = 0.7; + // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу. + this._pos.y += 0.5; + // 4) Загрузить новую модель. + this._modelTypeId = newType; + await this._loadPlayerModel(); + return !!this._modelRoot; + } + + /** + * Запустить режим игры. + * spawnPos — точка спавна. Если не указано — (0, 5, 0). + */ + async start(spawnPos = null) { + if (this._active) return; + this._active = true; + + if (spawnPos) { + this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z); + } else { + this._pos = new Vector3(0, 5 + this.HALF_H, 0); + } + this._vy = 0; + this._yaw = 0; + this._pitch = 0; + this._modelYaw = 0; + this._codes.clear(); + this._shift = false; + + // FPS-камера + const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene); + cam.minZ = 0.1; + cam.maxZ = 1000; + cam.fov = 1.05; + cam.inputs.clear(); + this.scene.activeCamera = cam; + this.camera = cam; + + this._setupInput(); + + // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит + // без меша (или появляется частично), а движение/колайдер уже + // активны. start() теперь async-функция — все её вызовы (`await`). + await this._loadPlayerModel(); + + // Render-loop hook + this._beforeRender = () => this._tick(); + this.scene.registerBeforeRender(this._beforeRender); + + // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу: + // - first / lockfirst — постоянный lock + // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления + // Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit). + // ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'. + const needLockAtStart = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needLockAtStart) { + this._requestPointerLockSafe(); + } + // Применяем видимость курсора (по умолчанию виден в third). + this._applyCursorVisibility(); + } + + /** + * Установить курсор видимым/скрытым через CSS на canvas. + * Pointer-lock сам прячет курсор когда активен, но в third без lock + * мы можем скрыть курсор через `cursor:none` если разработчик + * выключил его через setMouseIconVisible(false). + */ + _applyCursorVisibility() { + if (!this.canvas) return; + const locked = (document.pointerLockElement === this.canvas); + // Если lock активен — курсор и так скрыт. Иначе зависит от настроек. + if (locked) return; + const show = this._mouseIconVisible && !this._shiftLock; + this.canvas.style.cursor = show ? '' : 'none'; + } + + /** + * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых + * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём + * pointerlockchange и пробуем снова один раз. + */ + /** + * Включить/выключить «UI-режим курсора». + * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается. + * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false). + */ + /** Колбэк изменения HP — ({hp, maxHp}). */ + setOnHpChange(cb) { this._onHpChange = cb; } + setOnDeath(cb) { this._onDeath = cb; } + + /** Нанести урон игроку (с учётом i-frames). */ + takeDamage(amount, source) { + if (this.hp <= 0) return; + const now = performance.now() / 1000; + if (now - this._lastDamageTime < this._invulnerabilityTime) return; + this._lastDamageTime = now; + this.hp = Math.max(0, this.hp - Math.max(0, amount)); + // Flash-эффект для UI (через onHpChange флаг damaged=true) + if (this._onHpChange) { + try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {} + } + // Звук «ой» + this._playHurtSound(); + if (this.hp === 0) { + // Эффект распада + this._spawnDeathDebris(); + // Прячем модель игрока + if (this._modelRoot) this._modelRoot.setEnabled(false); + if (this._onDeath) { + try { this._onDeath(); } catch (e) {} + } + } + } + + /** Распад на куски при смерти. */ + _spawnDeathDebris() { + if (!this._pos) return; + const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z; + const colors = [ + new Color3(0.95, 0.78, 0.6), // кожа + new Color3(0.7, 0.5, 0.4), + new Color3(0.4, 0.4, 0.7), // одежда + new Color3(0.3, 0.25, 0.2), + ]; + for (let i = 0; i < 10; i++) { + const size = 0.18 + Math.random() * 0.14; + const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene); + const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene); + mat.diffuseColor = colors[i % colors.length]; + mat.specularColor = new Color3(0, 0, 0); + cube.material = mat; + cube.position.set( + cx + (Math.random() - 0.5) * 0.5, + cy + Math.random() * 0.6, + cz + (Math.random() - 0.5) * 0.5 + ); + cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); + cube.isPickable = false; + cube.alwaysSelectAsActiveMesh = true; + const debris = { + mesh: cube, mat, + vx: (Math.random() - 0.5) * 5, + vy: 4 + Math.random() * 3, + vz: (Math.random() - 0.5) * 5, + rx: (Math.random() - 0.5) * 10, + ry: (Math.random() - 0.5) * 10, + rz: (Math.random() - 0.5) * 10, + age: 0, + life: 2.0, + }; + if (!this._debris) this._debris = []; + this._debris.push(debris); + } + } + + /** Тик debris — вызывается в _tick. */ + _tickDebris(dt) { + if (!this._debris || this._debris.length === 0) return; + const G = -10; + const next = []; + for (const d of this._debris) { + d.age += dt; + if (d.age >= d.life) { + try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} + continue; + } + d.vy += G * dt; + d.mesh.position.x += d.vx * dt; + d.mesh.position.y += d.vy * dt; + d.mesh.position.z += d.vz * dt; + if (d.mesh.position.y < 0.1) { + d.mesh.position.y = 0.1; + d.vy *= -0.4; + d.vx *= 0.6; + d.vz *= 0.6; + } + d.mesh.rotation.x += d.rx * dt; + d.mesh.rotation.y += d.ry * dt; + d.mesh.rotation.z += d.rz * dt; + const fadeStart = d.life - 0.5; + if (d.age > fadeStart) { + const k = 1 - (d.age - fadeStart) / 0.5; + d.mesh.visibility = Math.max(0, k); + } + next.push(d); + } + this._debris = next; + } + + /** Короткий звук «ой» когда получили урон. */ + _playHurtSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(220, t); + osc.frequency.exponentialRampToValueAtTime(80, t + 0.15); + const g = ctx.createGain(); + g.gain.setValueAtTime(0.15, t); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.2); + osc.connect(g).connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.22); + } catch (e) { /* ignore */ } + } + + /** Полное восстановление HP (например при респавне). */ + healFull() { + this.hp = this.maxHp; + this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна + // Возвращаем модель + if (this._modelRoot) this._modelRoot.setEnabled(true); + // Сбрасываем оставшиеся debris + if (this._debris) { + for (const d of this._debris) { + try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {} + } + this._debris = []; + } + if (this._onHpChange) { + try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {} + } + } + + setUiCursorMode(enabled) { + this._uiCursorMode = !!enabled; + if (enabled) { + // Освобождаем мышь + if (document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) { /* ignore */ } + } + } else { + // Возвращаем lock — но только если мы реально активны + if (this._active) { + this._requestPointerLockSafe(); + } + } + } + isUiCursorMode() { return !!this._uiCursorMode; } + + /** + * Callback, который вызывается при движении мыши в UI-режиме. + * fn(x, y) — нормализованные координаты [0..1] относительно канваса. + * Используется для drag-механик (Дальгона и т.д.). + */ + setUiMouseMoveCallback(fn) { + this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null; + } + /** mousedown в UI-режиме. fn(x, y). */ + setUiMouseDownCallback(fn) { + this._uiMouseDownCb = (typeof fn === 'function') ? fn : null; + } + /** mouseup в UI-режиме. fn(x, y). */ + setUiMouseUpCallback(fn) { + this._uiMouseUpCb = (typeof fn === 'function') ? fn : null; + } + + _requestPointerLockSafe(retried = false) { + if (!this._active || !this.canvas?.requestPointerLock) return; + // На тач-устройствах pointer-lock не нужен — управление через touch-overlay + if (this._touchMode) return; + // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui')) + // — не захватываем мышь. + if (this._uiCursorMode) return; + // Если уже есть lock на этот canvas — нечего делать + if (document.pointerLockElement === this.canvas) return; + // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем + if (document.pointerLockElement && document.pointerLockElement !== this.canvas) { + if (retried) return; // только одна попытка повтора + const onChange = () => { + document.removeEventListener('pointerlockchange', onChange); + if (this._active) this._requestPointerLockSafe(true); + }; + document.addEventListener('pointerlockchange', onChange, { once: true }); + return; + } + requestAnimationFrame(() => { + if (!this._active) return; + try { + const p = this.canvas.requestPointerLock(); + // Promise-форма: ловим reject (SecurityError) и пробуем повтор + if (p && typeof p.catch === 'function') { + p.catch((err) => { + if (!this._active) return; + // SecurityError — попробуем ещё раз через кадр (один раз) + if (!retried && err && err.name === 'SecurityError') { + setTimeout(() => this._requestPointerLockSafe(true), 50); + } + }); + } + } catch (e) { /* legacy form, ignore */ } + }); + } + + /** + * Загрузить манифест R15-скинов (характеристики + overrides). + * Кешируется в this._skinManifest. Возвращает массив skins или []. + */ + async _loadSkinManifest() { + if (this._skinManifest) return this._skinManifest; + 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; + } + + /** + * Определить путь к GLB и overrides для текущего _modelTypeId. + * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста + * - иначе → старая Kenney-модель через getModelType() + * Возвращает { file, isR15, overrides } или null. + */ + async _resolveModelSource() { + const typeId = this._modelTypeId || 'character-a'; + if (typeId.startsWith('skin_')) { + const manifest = await this._loadSkinManifest(); + const entry = manifest.find((s) => s.id === typeId); + if (entry) { + // kind определяет систему анимации: + // 'r15' → R15-скелет (как раньше) + // 'non-humanoid-mesh' → single-mesh, процедурное покачивание + // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup + // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом). + const kind = entry.kind || 'r15'; + return { + file: '/kubikon-assets/' + entry.file, + isR15: kind === 'r15', + kind, + overrides: entry.overrides || {}, + scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null, + hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null, + rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0, + }; + } + // нет в манифесте — пробуем прямой путь (старые R15-скины) + return { + file: `/kubikon-assets/characters/${typeId}/body.glb`, + isR15: true, + kind: 'r15', + overrides: {}, + }; + } + // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные + // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs. + if (typeId.startsWith('customskin:')) { + const slug = typeId.slice('customskin:'.length); + const list = this._scene3d?._skinsConfig?.customGlbs || []; + const meta = list.find(g => g && g.slug === slug) || null; + const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null; + if (url) { + return { + file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {}, + scaleManifest: meta?.scale ?? 1.5, + hipHeight: meta?.hipHeight ?? 0.4, + rotationYOffset: meta?.rotationYOffset ?? 0, + isDataUrl: true, + }; + } + return null; + } + const modelType = getModelType(typeId); + if (!modelType) return null; + return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; + } + + /** Загрузить GLB-модель персонажа и его анимации. */ + async _loadPlayerModel() { + const source = await this._resolveModelSource(); + if (!source) return; + if (!this._active) return; + + // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш + // ModelManager. Если бы мы использовали тот же AssetContainer + // что и зомби (через _loadPrototype), повторный + // instantiateModelsToScene давал меши с битыми материалами. + // Babylon HTTP-кэш всё равно убирает сетевые запросы. + let rootUrl, filename; + if (source.isDataUrl) { + // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' + // и filename=data:... с подсказкой расширения через ?name=. + rootUrl = ''; + filename = source.file; + } else { + const lastSlash = source.file.lastIndexOf('/'); + rootUrl = source.file.substring(0, lastSlash + 1); + filename = source.file.substring(lastSlash + 1); + } + let container; + try { + container = await SceneLoader.LoadAssetContainerAsync( + rootUrl, filename, this.scene, + null, source.isDataUrl ? '.glb' : undefined + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[PlayerController] failed to load model:', e); + return; + } + try { + if (!this._active) { + try { container.dispose(); } catch (e) {} + return; + } + // Создаём корневой узел и инстанцируем модель туда + const root = new TransformNode('playerModel', this.scene); + // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока). + // - R15-скины ('skin_*'): фиксированный 0.301 — модели + // нормализованы к 5.98 ед пайплайном auto_rig_bacon + // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах + // с торчащими волосами/плащами (как у bacon-hair). + // - Kenney-модели: старый 0.72. + // - overrides.scale_mult — per-skin множитель из манифеста. + const isNonHumanoid = source.kind === 'non-humanoid-mesh' + || source.kind === 'non-humanoid-rigged'; + let modelScale; + if (isNonHumanoid) { + // Non-humanoid: базовый размер берём из манифеста (scale), а если + // нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок). + modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0; + } else { + modelScale = source.isR15 ? 0.301 : this._modelScale; + const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0; + modelScale *= scaleMult; + } + root.scaling = new Vector3(modelScale, modelScale, modelScale); + if (source.rotationYOffset) root.rotation.y = source.rotationYOffset; + const inst = container.instantiateModelsToScene( + (name) => `player_${name}`, + /*cloneAnimations*/ true, + { doNotInstantiate: false } + ); + for (const r of inst.rootNodes) { + r.parent = root; + } + this._modelRoot = root; + this._modelKind = source.kind || 'r15'; + // hipHeight: на сколько центр модели поднят от «низа ног». + // Используется и для позиционирования модели, и для камеры/AABB. + this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null; + + // Non-humanoid: нормализуем размер и опускаем модель на «ноги». + if (isNonHumanoid) { + this._setupNonHumanoidModel(root, modelScale, source); + } + + // === R15-скин: детекция скелета === + // R15-скины приходят с встроенным скелетом Mixamo. Babylon + // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер + // и, если скелет валидный, помечаем _isR15 + создаём аниматор. + this._isR15 = false; + this._r15Skeleton = null; + this._r15Animator = null; + this._skinOverrides = source.overrides || {}; + // eslint-disable-next-line no-console + console.log('[PlayerController] _loadPlayerModel: file=' + source.file + + ' isR15=' + source.isR15 + + ' inst.skeletons=' + ((inst.skeletons || []).length) + + ' rootNodes=' + (inst.rootNodes || []).length); + if (source.isR15) { + // Скелет ищем в нескольких местах: inst.skeletons (норма), + // container.skeletons (иногда не клонируется), на мешах + // модели (skeleton-property). Берём первый найденный. + let sk = (inst.skeletons && inst.skeletons[0]) || null; + if (!sk && container.skeletons && container.skeletons.length > 0) { + sk = container.skeletons[0]; + } + if (!sk) { + const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton); + if (meshWithSkel) sk = meshWithSkel.skeleton; + } + if (sk) { + // eslint-disable-next-line no-console + console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length + + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(',')); + const r15 = new R15Skeleton(sk); + if (r15.isValidR15()) { + this._r15Skeleton = r15; + this._isR15 = true; + this._r15Animator = new R15Animator(r15, this._skinOverrides); + // eslint-disable-next-line no-console + console.log('[PlayerController] R15-скин загружен:', + this._modelTypeId, '— костей:', r15.resolvedNames().length, + 'overrides:', JSON.stringify(this._skinOverrides)); + } else { + // eslint-disable-next-line no-console + console.warn('[PlayerController] R15-скин', this._modelTypeId, + '— скелет не прошёл валидацию. Зарезолвлено:', + r15.resolvedNames().join(','), + '| все кости скелета:', + (sk.bones || []).map(b => b.name).join(',')); + } + } else { + // eslint-disable-next-line no-console + console.warn('[PlayerController] R15-скин', this._modelTypeId, + '— нет скелета в glb'); + } + } + + // Собираем все mesh-чилдрены (для toggle visibility в 1st person) + this._modelMeshes = root.getChildMeshes(false); + // Игрок не должен ловить свой raycast → отключаем pickable + for (const m of this._modelMeshes) { + m.isPickable = false; + if (m.alwaysSelectAsActiveMesh !== undefined) { + m.alwaysSelectAsActiveMesh = true; + } + // Тени: персонаж принимает тени от мира и сам отбрасывает. + m.receiveShadows = true; + } + try { + if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') { + for (const m of this._modelMeshes) { + this._scene3d.addShadowCaster(m); + } + } + } catch (e) { /* ignore */ } + // У Kenney character имена `arm-left`/`arm-right` соответствуют + // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right). + // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его + // правая рука у нас слева на экране. + // + // Берём именно arm-right (его правую) — это та рука куда логично + // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4. + this._rightArmMeshes = []; + for (const m of this._modelMeshes) { + const n = (m.name || '').toLowerCase(); + // Берём именно «right» — настоящая правая рука персонажа + if (n === 'player_arm-right' || n.endsWith('arm-right') + || n.includes('right-arm') || n.includes('rightarm') + || n.includes('right-hand') || n.includes('hand-right')) { + this._rightArmMeshes.push(m); + break; + } + } + // Fallback: если по имени не нашли — берём левую по позиции (x<0) + if (this._rightArmMeshes.length === 0) { + let bestMesh = null; + let bestX = Infinity; + for (const m of this._modelMeshes) { + if (!m.position) continue; + const n = (m.name || '').toLowerCase(); + if (n.includes('leg') || n.includes('foot') || n.includes('head') + || n.includes('hat') || n.includes('torso') || n.includes('root')) continue; + const px = m.position.x; + const py = m.position.y; + if (py < 0.8 || py > 2.2) continue; + if (px >= -0.05) continue; // ищем X<0 + if (px < bestX) { bestX = px; bestMesh = m; } + } + if (bestMesh) this._rightArmMeshes = [bestMesh]; + } + const arm = this._rightArmMeshes[0]; + if (arm) { + this._rightArmX = arm.position.x; + this._rightArmY = arm.position.y; + this._rightArmZ = arm.position.z; + } + + // Анимации. + // R15-скины не содержат AnimationGroups (анимируются процедурно + // через R15Animator в _tick). Kenney-модели — наоборот, имеют + // встроенные AnimationGroups (idle/walk/sprint/jump). + this._animations = {}; + if (!this._isR15) { + const groups = inst.animationGroups || []; + for (const g of groups) { + const name = (g.name || '').toLowerCase(); + if (name.includes('idle')) this._animations.idle = g; + else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g; + else if (name.includes('walk')) this._animations.walk = g; + else if (name.includes('jump')) this._animations.jump = g; + g.stop(); + } + this._playAnim('idle'); + } + // Применяем текущий camera-mode (показать/скрыть модель) + this._applyCameraMode(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[PlayerController] failed to load model:', e); + } + } + + /** + * Настройка non-humanoid модели (животное/машина/еда): нормализация + * размера и опускание на «низ ног». В отличие от R15 (нормализованы + * пайплайном), эти модели произвольного размера, поэтому считаем bbox. + * + * Локальные координаты root: модель должна стоять так, чтобы её низ был + * на y=0 (там «ноги»). PlayerController позиционирует root в точке + * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю. + */ + _setupNonHumanoidModel(root, scaleApplied, source) { + try { + // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ + // применения scaling root'а. Babylon refreshBoundingInfo нужен после + // инстансинга. + const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0); + if (!meshes.length) return; + root.computeWorldMatrix(true); + let minY = Infinity, maxY = -Infinity, maxDim = 0; + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const m of meshes) { + m.computeWorldMatrix(true); + // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного + // скелета/морфов; без него minimumWorld у инстансов часто нулевой + // или из исходной позы → центр считался неверно (баг пришельца/робота). + try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} } + const bi = m.getBoundingInfo(); + const bb = bi.boundingBox; + const lo = bb.minimumWorld, hi = bb.maximumWorld; + if (!lo || !hi) continue; + minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y); + minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x); + minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z); + } + if (!Number.isFinite(minX) || !Number.isFinite(minY)) return; + const h = maxY - minY; + const w = maxX - minX; + const d = maxZ - minZ; + maxDim = Math.max(h, w, d); + // === Центрирование модели через pivot-node === + // Многие Kenney-модели имеют origin НЕ в геометрическом центре + // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого + // origin (баг пришельца/робота). Ручной сдвиг детей с делением на + // scaleApplied неверен если у детей свой scale/rotation. Надёжно: + // вставляем промежуточный pivot между root и моделью и смещаем pivot + // на -localCenter (через инверсию world-матрицы root — точно при + // любом scale/rotation). + const worldCenter = new Vector3( + (minX + maxX) / 2, // центр X + minY, // низ Y (модель «садится» на ноги) + (minZ + maxZ) / 2 // центр Z + ); + // world-центр → локальные координаты root + const invRoot = root.getWorldMatrix().clone().invert(); + const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot); + const pivot = new TransformNode('playerModelPivot', this.scene); + pivot.parent = root; + pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z); + // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot. + for (const ch of root.getChildren().slice()) { + if (ch === pivot) continue; + ch.parent = pivot; + } + // Сохраняем размеры для настраиваемого AABB и камеры. + // hipHeight из манифеста — приоритетно; иначе берём низ модели. + this._nonHumanoidBox = { w, h, d }; + this._modelBaseHeight = h; + // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды). + // Ограничиваем разумными пределами чтобы не проваливаться/застревать. + this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2)); + this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2)); + const halfH = Math.max(0.3, Math.min(1.0, h / 2)); + this.HALF_H = halfH; + this.HALF_H_NORMAL = halfH; + this.EYE_HEIGHT = halfH * 0.7; + // eslint-disable-next-line no-console + console.log('[PlayerController] non-humanoid setup:', this._modelTypeId, + 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2), + 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[PlayerController] _setupNonHumanoidModel failed:', e); + } + } + + /** + * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать + * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при + * беге + наклон в воздухе. Вызывается каждый кадр из _tick. + * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel). + */ + _animateNonHumanoidMesh(dt) { + const root = this._modelRoot; + if (!root) return; + const t = (typeof performance !== 'undefined' && performance.now) + ? performance.now() / 1000 : Date.now() / 1000; + const speed = this._lastFrameSpeed || 0; + // Базовое вращение по yaw уже выставляет _tick (он крутит модель под + // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt + // поверх — храним их в отдельных полях, чтобы _tick их не перетёр. + let bobY = 0, tiltX = 0; + if (!this._isGrounded) { + tiltX = 0.2; // в воздухе — нос вверх + } else if (speed > 0.1) { + const bobFreq = 8 * Math.min(2, speed / 4); + bobY = Math.sin(t * bobFreq) * 0.06; + tiltX = Math.min(speed * 0.04, 0.13); + } else { + bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое + } + // Применяем поверх позиции, которую _tick уже выставил в root.position.y. + root.position.y += bobY; + // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом. + root.rotation.x = tiltX; + } + + /** AABB игрока пересекает хотя бы один блок-воду. */ + _isInWater() { + const bm = this._scene3d?.blockManager; + if (!bm) return false; + // FAST PATH: если на сцене нет водных блоков — точно не в воде. + // Большинство карт (зомби-остров, любые «суховые») — без воды, + // и тройной цикл ниже бесполезно тратит время каждый кадр. + if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; + const px = this._pos.x, py = this._pos.y, pz = this._pos.z; + const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D; + // Проверяем клетки которые AABB перекрывает + const gxMin = Math.floor(px - hw + 0.5); + const gxMax = Math.floor(px + hw + 0.5); + const gzMin = Math.floor(pz - hd + 0.5); + const gzMax = Math.floor(pz + hd + 0.5); + const gyMin = Math.floor(py - hh); + const gyMax = Math.floor(py + hh); + for (let gx = gxMin; gx <= gxMax; gx++) { + for (let gy = gyMin; gy <= gyMax; gy++) { + for (let gz = gzMin; gz <= gzMax; gz++) { + const m = bm.blocks.get(`${gx},${gy},${gz}`); + if (m?.metadata?.isWater) return true; + } + } + } + return false; + } + + /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */ + _isSubmerged() { + const bm = this._scene3d?.blockManager; + if (!bm) return false; + // FAST PATH: нет воды на сцене — не утопаем. + if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false; + // Проверяем «голову» — точку чуть ниже верха AABB + const headY = this._pos.y + this.HALF_H - 0.1; + const gx = Math.round(this._pos.x); + const gy = Math.floor(headY); + const gz = Math.round(this._pos.z); + const m = bm.blocks.get(`${gx},${gy},${gz}`); + return !!m?.metadata?.isWater; + } + + /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */ + _playFootstep() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + + const now = ctx.currentTime; + const duration = 0.08; + + // Источник — короткий шумовой буфер + const sampleRate = ctx.sampleRate; + const length = Math.floor(sampleRate * duration); + const buffer = ctx.createBuffer(1, length, sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < length; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум + } + const src = ctx.createBufferSource(); + src.buffer = buffer; + + // Lowpass для тяжёлого «тук» вместо высокого «шшш» + const lowpass = ctx.createBiquadFilter(); + lowpass.type = 'lowpass'; + lowpass.frequency.value = 350; + lowpass.Q.value = 1.5; + + // Envelope (быстрая атака, быстрое затухание) + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.7, now + 0.005); + gain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + src.connect(lowpass).connect(gain).connect(ctx.destination); + src.start(now); + src.stop(now + duration); + } catch (e) { + // ignore — звук не критичен + } + } + + /** + * Звук прыжка — мягкий «boing» из двух слоёв: + * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами» + * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма» + * Гораздо приятнее старого квадратного восходящего тона. + */ + /** + * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation. + * Работает только для R15-скинов (Kenney-модели эмоций не имеют). + */ + playEmote(name) { + if (this._isR15 && this._r15Animator) { + return this._r15Animator.playEmote(name); + } + return false; + } + + /** Прервать текущую эмоцию персонажа. */ + stopEmote() { + if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote(); + } + + _playJumpSound() { + // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок + // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся. + if (typeof this._onJump === 'function') { + try { this._onJump(); } catch (e) { /* ignore */ } + } + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const now = ctx.currentTime; + const out = ctx.destination; + + // Слой 1: низкий thump + const thumpDur = 0.07; + const thumpOsc = ctx.createOscillator(); + thumpOsc.type = 'sine'; + thumpOsc.frequency.setValueAtTime(110, now); + thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur); + const thumpGain = ctx.createGain(); + thumpGain.gain.setValueAtTime(0, now); + thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005); + thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur); + thumpOsc.connect(thumpGain).connect(out); + thumpOsc.start(now); + thumpOsc.stop(now + thumpDur + 0.02); + + // Слой 2: «boing» — pitch-down sine + const boingDur = 0.18; + const boingOsc = ctx.createOscillator(); + boingOsc.type = 'sine'; + boingOsc.frequency.setValueAtTime(720, now + 0.005); + boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur); + const boingGain = ctx.createGain(); + boingGain.gain.setValueAtTime(0, now + 0.005); + boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02); + boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur); + // Лёгкое vibrato чтобы было «живее» + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 14; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 18; // ±18 Hz + lfo.connect(lfoGain).connect(boingOsc.frequency); + boingOsc.connect(boingGain).connect(out); + boingOsc.start(now + 0.005); + lfo.start(now + 0.005); + boingOsc.stop(now + 0.005 + boingDur + 0.02); + lfo.stop(now + 0.005 + boingDur + 0.02); + } catch (e) { /* ignore */ } + } + + /** Звук «бульк» при входе/выходе из воды. */ + _playSplashSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const now = ctx.currentTime; + const duration = 0.35; + + // Шум с быстро падающим highpass — звук «всплеска» + const length = Math.floor(ctx.sampleRate * duration); + const buf = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < length; i++) { + data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2; + } + const src = ctx.createBufferSource(); + src.buffer = buf; + + const bp = ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.setValueAtTime(2000, now); + bp.frequency.exponentialRampToValueAtTime(400, now + duration); + bp.Q.value = 1.5; + + const g = ctx.createGain(); + g.gain.setValueAtTime(0, now); + g.gain.linearRampToValueAtTime(0.6, now + 0.01); + g.gain.exponentialRampToValueAtTime(0.001, now + duration); + + src.connect(bp).connect(g).connect(ctx.destination); + src.start(now); + src.stop(now + duration); + } catch (e) { /* ignore */ } + } + + _playAnim(name) { + if (this._currentAnim === name) return; + const target = this._animations[name]; + if (!target) { + // если нужной нет — пробуем idle как fallback + if (name !== 'idle' && this._animations.idle) { + return this._playAnim('idle'); + } + return; + } + // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ === + // Помогает дебажить вибрацию ног на склонах: если в логе видно + // частое мерцание walk↔jump или idle↔walk — значит onGround или + // isMoving скачет каждые несколько кадров. + const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false }; + console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} ` + + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`); + // Стопим текущую + if (this._currentAnim && this._animations[this._currentAnim]) { + this._animations[this._currentAnim].stop(); + } + target.start(/*loop*/ true, /*speed*/ 1); + this._currentAnim = name; + } + + /** + * Остановить режим игры. Освобождает все ресурсы. + */ + stop() { + if (!this._active) return; + this._active = false; + + if (this._beforeRender) { + this.scene.unregisterBeforeRender(this._beforeRender); + this._beforeRender = null; + } + for (const { target, type, fn, opts } of this._listeners) { + target.removeEventListener(type, fn, opts); + } + this._listeners = []; + + if (document.pointerLockElement === this.canvas) { + document.exitPointerLock(); + } + + // Останавливаем все анимации + for (const g of Object.values(this._animations)) { + try { g.stop(); } catch (e) { /* ignore */ } + } + this._animations = {}; + this._currentAnim = null; + + // Удаляем якорь оружия + if (this._weaponAnchor) { + try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ } + this._weaponAnchor = null; + } + // Сбрасываем все override'ы вращения + if (this._meshRotationOverrides) { + this._meshRotationOverrides.clear(); + } + // Сброс R15-состояния + if (this._r15BoneOverrides) this._r15BoneOverrides.clear(); + this._r15Animator = null; + this._r15Skeleton = null; + this._isR15 = false; + + // Удаляем модель + if (this._modelRoot) { + for (const m of this._modelMeshes) { + try { m.dispose(); } catch (e) { /* ignore */ } + } + try { this._modelRoot.dispose(); } catch (e) { /* ignore */ } + this._modelRoot = null; + this._modelMeshes = []; + } + + if (this.camera) { + this.camera.dispose(); + this.camera = null; + } + + if (this._audioCtx) { + try { this._audioCtx.close(); } catch (e) { /* ignore */ } + this._audioCtx = null; + } + } + + isActive() { + return this._active; + } + + // === ВНУТРЕННЕЕ === + + /** Позиция камеры в мире (зависит от режима first/third/front). */ + _computeCameraPos() { + // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up + // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем» + // высоту чтобы камера не дёргалась рывком при step-up. + const visY = this._pos.y - this._stepUpVisualOffset; + if (this._cameraMode === 'first') { + return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z); + } + if (this._cameraMode === 'sideview') { + // Кубикон Dash: камера сбоку, фиксированный yaw на куб. + // Игрок движется по +X, камера в -Z от него (смотрит на +Z). + // С этого ракурса +X на экране = вправо (как в Geometry Dash). + // Лёгкое смещение camera по X влево от куба — игрок в левой + // трети кадра, впереди видно больше уровня. + return new Vector3( + this._pos.x - 1.5, + visY + SIDEVIEW_HEIGHT, + this._pos.z - SIDEVIEW_DIST + ); + } + // forward — направление «куда смотрит игрок» с учётом yaw и pitch + const cosP = Math.cos(this._pitch); + const fx = Math.sin(this._yaw) * cosP; + const fy = -Math.sin(this._pitch); + const fz = Math.cos(this._yaw) * cosP; + const dist = this._thirdDistance; + + // Точка «глаз» игрока — отсюда пускаем луч к запланированной + // позиции камеры и сокращаем дистанцию если упёрлись в стену. + const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET; + + if (this._cameraMode === 'third') { + const desired = new Vector3( + this._pos.x - fx * dist, + eyeY - fy * dist, + this._pos.z - fz * dist + ); + return this._clampCameraToWorld( + this._pos.x, eyeY, this._pos.z, desired + ); + } + // 'front' — спереди игрока, направлена назад (на лицо) + const desiredFront = new Vector3( + this._pos.x + fx * dist, + eyeY + fy * dist, + this._pos.z + fz * dist + ); + return this._clampCameraToWorld( + this._pos.x, eyeY, this._pos.z, desiredFront + ); + } + + /** + * Не позволяет камере «проходить» сквозь стены/блоки/примитивы. + * Пускает луч от глаз игрока до запланированной позиции камеры. + * Если на пути есть препятствие — возвращает точку чуть ближе + * (hit.distance - PADDING), чтобы камера прижалась к стене. + * + * Игнорирует: + * - меши без metadata (вспомогательная техника редактора), + * - триггеры (canCollide===false), + * - саму модель игрока, + * - debris/particles. + */ + _clampCameraToWorld(ex, ey, ez, desired) { + if (!this.scene) return desired; + // Вектор от глаз до желаемой камеры. + const dx = desired.x - ex; + const dy = desired.y - ey; + const dz = desired.z - ez; + const len = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт + const dir = new Vector3(dx / len, dy / len, dz / len); + const origin = new Vector3(ex, ey, ez); + const ray = new Ray(origin, dir, len); + + const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась» + const playerRoot = this._modelRoot; + + const pickPred = (mesh) => { + if (!mesh) return false; + if (!mesh.isEnabled || !mesh.isEnabled()) return false; + // Прозрачно-визуальные и technical meshes — пропускаем. + if (mesh.isPickable === false) return false; + const md = mesh.metadata || {}; + // Триггеры/невидимки/скриптовые маркеры — не блокируют. + if (md.canCollide === false) return false; + if (md._isTriggerHelper) return false; + // Модель игрока (камера не должна цепляться за собственный меш). + if (playerRoot) { + let n = mesh; + while (n) { + if (n === playerRoot) return false; + n = n.parent; + } + } + // Жидкости (вода/лава) — не блокируют камеру. + if (md._liquidProxy) return false; + return true; + }; + + let hit = null; + try { + hit = this.scene.pickWithRay(ray, pickPred); + } catch (e) { + return desired; + } + if (!hit || !hit.hit || hit.distance >= len - 0.01) { + return desired; + } + // Сокращаем дистанцию. + const clampedLen = Math.max(0.3, hit.distance - PADDING); + return new Vector3( + ex + dir.x * clampedLen, + ey + dir.y * clampedLen, + ez + dir.z * clampedLen + ); + } + + // ===== Управление камерой из скрипта (Фаза 5.7) ===== + + /** Установить угол обзора камеры (FOV) в градусах. */ + setCameraFov(degrees) { + if (!this.camera) return; + const d = Number(degrees); + if (!Number.isFinite(d) || d < 10 || d > 130) return; + this.camera.fov = d * Math.PI / 180; + } + + /** + * Привязать камеру к объекту — она смотрит на него. + * getTarget — функция, возвращающая {x,y,z} цели. + * opts: { distance, height } — отступ камеры от цели. + */ + cameraFocusOn(getTarget, opts = {}) { + if (typeof getTarget !== 'function') return; + this._cameraOverride = { + mode: 'focus', + getTarget, + distance: Number.isFinite(opts.distance) ? opts.distance : 8, + height: Number.isFinite(opts.height) ? opts.height : 4, + }; + } + + /** + * Катсцена — плавный пролёт камеры по точкам. + * points — массив {x,y,z} позиций камеры. + * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию). + * segDuration — секунд на отрезок между точками. + * onDone — колбэк по завершении. + */ + cameraCutscene(points, lookAt, segDuration, onDone) { + if (!Array.isArray(points) || points.length < 2) return; + this._cameraOverride = { + mode: 'cutscene', + points, + lookAt: Array.isArray(lookAt) ? lookAt : [], + segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2, + t: 0, // время от начала + seg: 0, // текущий отрезок + onDone: typeof onDone === 'function' ? onDone : null, + }; + } + + /** Вернуть камеру под управление игрока. */ + cameraReset() { + this._cameraOverride = null; + } + + // ===== Задача 14: вождение машины ===== + /** Усадить игрока в машину veh (объект из VehicleManager). */ + enterVehicle(veh) { + if (!veh) return; + this._inVehicle = veh; + this._vehicleCamMode = 'follow'; + veh.driver = 'player'; + if (this._codes) this._codes.clear(); // сброс остаточных WASD от ходьбы + this._skinVisibleScripted = false; // спрятать аватар (сидит в машине) + this._startEngineSound(); + } + + /** + * Звук мотора: низкочастотный РОКОТ (а не воющий тон). + * Бас-пила (40-90 Гц) + отфильтрованный шум для «рыка» + LFO-пульсация + * громкости (имитация тактов двигателя). Частота тактов и фильтр растут + * со скоростью, но базовый тон остаётся низким — не сирена. + */ + _startEngineSound() { + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + if (this._engineNodes) return; // уже играет + + // 1) Бас-тон мотора (низкий, тёплый). + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = 45; // холостой ход — низко + // 2) Шум через узкий lowpass — «рык» выхлопа. + const bufLen = ctx.sampleRate * 1.0; + const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6; + const noise = ctx.createBufferSource(); + noise.buffer = buf; noise.loop = true; + const noiseLp = ctx.createBiquadFilter(); + noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7; + const noiseGain = ctx.createGain(); + noiseGain.gain.value = 0.35; + // Общий lowpass — глушит верха, чтобы не свистело. + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5; + // 3) LFO-пульсация громкости (такты двигателя ~12 Гц на холостых). + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; lfo.frequency.value = 12; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 0.18; // глубина пульсации + const gain = ctx.createGain(); + gain.gain.value = 0.05; // общая громкость (растёт со скоростью) + + osc.connect(lp); + noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp); + lp.connect(gain); gain.connect(ctx.destination); + // LFO модулирует gain.gain вокруг базового значения. + lfo.connect(lfoGain); lfoGain.connect(gain.gain); + + osc.start(); noise.start(); lfo.start(); + this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain }; + } catch (e) { /* ignore */ } + } + + _updateEngineSound(speedMs, maxSpeed) { + const n = this._engineNodes; + if (!n) return; + try { + const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14)); + const ctx = this._audioCtx; const t = ctx.currentTime; + // Базовый тон остаётся НИЗКИМ: 45 Гц холостые → ~95 Гц на максималке. + n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12); + // Частота тактов (LFO): 12 Гц холостые → ~45 Гц на ходу (плотнее рокот). + n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12); + // Фильтры чуть открываются — больше «рыка», но без визга. + n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12); + n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12); + n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12); + // Громкость: 0.05 холостые → 0.13 в движении. + n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12); + } catch (e) { /* ignore */ } + } + + _stopEngineSound() { + const n = this._engineNodes; + if (!n) return; + try { + const t = this._audioCtx.currentTime; + n.gain.gain.setTargetAtTime(0, t, 0.05); + n.osc.stop(t + 0.2); + n.noise.stop(t + 0.2); + n.lfo.stop(t + 0.2); + } catch (e) { /* ignore */ } + this._engineNodes = null; + } + + /** Высадить игрока из машины (вернуть ходьбу). */ + exitVehicle() { + const veh = this._inVehicle; + this._inVehicle = null; + if (veh) { + veh.driver = null; + // Поставить игрока сбоку от машины. + try { + const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw)); + this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0)); + this._vy = 0; + } catch (e) {} + } + this._stopEngineSound(); + this._skinVisibleScripted = true; + // Вернуть видимость аватара. + if (this._modelMeshes) { + for (const m of this._modelMeshes) { + if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} } + } + } + } + + /** Циклически сменить режим камеры машины (клавиша V). */ + cycleVehicleCamera() { + const modes = ['follow', 'hood', 'cinematic']; + const i = modes.indexOf(this._vehicleCamMode || 'follow'); + this._vehicleCamMode = modes[(i + 1) % modes.length]; + } + + /** Шаг вождения: читаем WASD, рулим машиной, ставим camera follow/hood. */ + _tickVehicle(dt) { + const veh = this._inVehicle; + if (!veh || !this._scene3d?.vehicleManager) return; + // Скрываем аватар игрока (сидит в машине) — каждый кадр, т.к. обычный + // хвост _tick (где применяется _skinVisibleScripted) пропускается из-за return. + if (this._modelMeshes) { + for (const m of this._modelMeshes) { + if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} } + } + } + const c = this._codes; + const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0); + const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0); + const handbrake = c.has('Space'); + this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake); + const res = this._scene3d.vehicleManager.tickVehicle(veh, dt); + this._updateEngineSound(veh.speed, veh.params?.maxSpeed); + + // Упали в бездну на машине → выйти + респавн на точке старта. + if (res && res.fellOut) { + this.exitVehicle(); + if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} } + const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; + try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {} + return; + } + + // Синхронизируем позицию игрока с машиной (для квестов/выхода). + try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {} + + // Камера машины. + if (!this.camera) return; + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const cp = veh.pos; + const mode = this._vehicleCamMode || 'follow'; + let camPos, camTarget; + if (mode === 'hood') { + camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3)); + camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8); + } else if (mode === 'cinematic') { + const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw)); + camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2); + camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z); + } else { // follow + camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8); + camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2); + } + // Плавный lerp позиции камеры (без рывков на поворотах). + const k = Math.min(1, dt * 6); + this.camera.position.set( + this.camera.position.x + (camPos.x - this.camera.position.x) * k, + this.camera.position.y + (camPos.y - this.camera.position.y) * k, + this.camera.position.z + (camPos.z - this.camera.position.z) * k, + ); + try { this.camera.setTarget(camTarget); } catch (e) {} + } + + /** Применить активный режим камеры скрипта (вызывается в _tick). */ + _applyCameraOverride(dt) { + const o = this._cameraOverride; + if (!o || !this.camera) return; + if (o.mode === 'focus') { + const t = o.getTarget(); + if (!t) return; + // Камера позади-сверху цели, смотрит на неё. + this.camera.position.set(t.x, t.y + o.height, t.z + o.distance); + this.camera.setTarget(new Vector3(t.x, t.y, t.z)); + } else if (o.mode === 'cutscene') { + // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB) + // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается» + // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт + // ровно свою длительность независимо от лагов. + o.t += Math.min(dt, 1 / 30); + const segCount = o.points.length - 1; + // Прогресс по текущему отрезку [0..1]. + let local = o.t / o.segDuration; + let seg = Math.floor(o.t / o.segDuration); + if (seg >= segCount) { + // Катсцена завершена — встаём на последнюю точку. + const last = o.points[o.points.length - 1]; + this.camera.position.set(last.x, last.y, last.z); + const lookLast = o.lookAt[o.lookAt.length - 1]; + if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z)); + const cb = o.onDone; + this._cameraOverride = null; + if (cb) { try { cb(); } catch (e) { /* ignore */ } } + return; + } + local = local - seg; // дробная часть = прогресс отрезка + // Сглаживание (ease-in-out) — плавный пролёт. + const k = local < 0.5 + ? 2 * local * local + : 1 - Math.pow(-2 * local + 2, 2) / 2; + const a = o.points[seg], b = o.points[seg + 1]; + this.camera.position.set( + a.x + (b.x - a.x) * k, + a.y + (b.y - a.y) * k, + a.z + (b.z - a.z) * k, + ); + // Точка взгляда — интерполяция между соседними lookAt. + const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg]; + if (la && lb) { + this.camera.setTarget(new Vector3( + la.x + (lb.x - la.x) * k, + la.y + (lb.y - la.y) * k, + la.z + (lb.z - la.z) * k, + )); + } + } + } + + /** + * Применить текущий режим камеры: + * - В 1st person скрываем модель игрока (видим только сцену) + * - В 3rd person и front показываем + */ + _applyCameraMode() { + const visible = this._cameraMode !== 'first'; + for (const m of this._modelMeshes) { + m.setEnabled(visible); + } + // Сообщаем оружию что режим камеры сменился — чтобы перепарентить + // view-model (камера в 1-st, модель игрока в 3-rd). + if (this._scene3d?.weapons?.onCameraModeChange) { + this._scene3d.weapons.onCameraModeChange(this._cameraMode); + } + } + + /** + * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели. + * + * Установить override-rotation для именованного меша поверх анимации. + * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion, + * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет + * наши углы Эйлера. + * + * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...). + * Без префикса 'player_'. + * rotation — Vector3 углов Эйлера (rad). null → снять override. + * + * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты). + */ + setMeshRotationOverride(meshName, rotation) { + // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт + // этот метод для позы/замаха — переадресуем на override кости. + // R15Animator каждый кадр ставит rest+анимацию; override кости + // применяется поверх в _applyR15BoneOverrides() после update(). + if (this._isR15) { + if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map(); + // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость. + const lower = (meshName || '').toLowerCase(); + const logical = (lower.includes('right')) ? 'RightUpperArm' + : (lower.includes('left')) ? 'LeftUpperArm' + : 'RightUpperArm'; + if (rotation == null) { + this._r15BoneOverrides.delete(logical); + } else { + this._r15BoneOverrides.set(logical, + rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); + } + return; + } + if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map(); + if (rotation == null) { + this._meshRotationOverrides.delete(meshName); + } else { + this._meshRotationOverrides.set(meshName, + rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z)); + } + } + + /** + * Применить override-повороты костей R15 поверх процедурной анимации. + * Вызывается после R15Animator.update(). Используется WeaponSystem + * для позы руки с оружием / melee-замаха. + */ + _applyR15BoneOverrides() { + const map = this._r15BoneOverrides; + if (!map || map.size === 0 || !this._r15Skeleton) return; + for (const [logical, rot] of map.entries()) { + const bone = this._r15Skeleton.resolveBone(logical); + if (!bone) continue; + // Override задаётся как абсолютный локальный поворот кости + // (Эйлер). Перекрывает то, что поставил аниматор этим кадром. + const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z); + bone.setRotationQuaternion(q, Space.LOCAL); + } + } + + /** Получить меш модели по короткому имени (без префикса 'player_'). */ + getModelMesh(meshName) { + if (!this._modelMeshes) return null; + const target = `player_${meshName}`; + return this._modelMeshes.find(m => m.name === target) || null; + } + + /** + * Применить все активные override'ы. Вызывается каждый кадр в _tick + * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после + * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его + * каждый кадр и пишем в .rotation. + */ + _applyMeshRotationOverrides() { + const map = this._meshRotationOverrides; + if (!map || map.size === 0) return; + for (const [meshName, rot] of map.entries()) { + const mesh = this.getModelMesh(meshName); + if (!mesh) continue; + if (mesh.rotationQuaternion) { + mesh.rotationQuaternion = null; + } + mesh.rotation.x = rot.x; + mesh.rotation.y = rot.y; + mesh.rotation.z = rot.z; + } + } + + /** + * Включить/выключить «позу с оружием». + * Делает 2 вещи независимо: + * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney). + * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает + * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0, + * и дуло автоматически смотрит вперёд персонажа. + * + * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять + * ориентацию повёрнутого меша руки. + */ + _updateExtendedArm(hasWeapon) { + // === R15-скин: якорь оружия на кости RightHand === + // У R15 нет меша-руки — есть кость. Якорь привязываем к кости + // через attachToBone: оружие следует за рукой при анимации. + if (this._isR15 && this._r15Skeleton) { + const showWeapon = hasWeapon && this._cameraMode !== 'first'; + if (showWeapon && !this._weaponAnchor) { + const handBone = this._r15Skeleton.resolveBone('RightHand'); + const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0]; + if (handBone && skinMesh) { + this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); + // attachToBone — якорь следует за костью каждый кадр. + this._weaponAnchor.attachToBone(handBone, skinMesh); + // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье. + this._weaponAnchor.position.set(0, 0.05, 0.1); + } + } + if (this._weaponAnchor) { + this._weaponAnchor.setEnabled(showWeapon); + } + return; + } + + const armMesh = this._rightArmMeshes?.[0]; + if (!armMesh) return; + const meshName = (armMesh.name || '').replace(/^player_/, ''); + const showWeapon = hasWeapon && this._cameraMode !== 'first'; + + // 1) Поза руки через override + if (showWeapon) { + this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0)); + } else { + this.setMeshRotationOverride(meshName, null); + } + + // 2) ChIstый якорь для оружия — TransformNode на плече персонажа, + // ориентация совпадает с _modelRoot (без поворотов). + if (showWeapon && !this._weaponAnchor && this._modelRoot) { + this._weaponAnchor = new TransformNode('weaponAnchor', this.scene); + this._weaponAnchor.parent = this._modelRoot; + // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша. + // Плечо: y = origin + 0.7 (выше). + // X: сдвигаем чуть наружу (ещё правее). + const sx = -(armMesh.position?.x ?? -0.4) + 0.15; + const sy = (armMesh.position?.y ?? 1.1) + 0.7; + const sz = (armMesh.position?.z ?? 0) + 0.95; + this._weaponAnchor.position.set(sx, sy, sz); + } + if (this._weaponAnchor) { + this._weaponAnchor.setEnabled(showWeapon); + } + } + + getWeaponAnchor() { return this._weaponAnchor || null; } + + /** Цикл first ↔ third. */ + _toggleCameraMode() { + const idx = CAMERA_MODES.indexOf(this._cameraMode); + this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length]; + this._applyCameraMode(); + // При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock) + if (this._cameraMode === 'first') { + this._requestPointerLockSafe(); + } else if (!this._shiftLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + this._applyCursorVisibility?.(); + } + + /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус + * всегда лицом к камере, камера через плечо). + */ + setShiftLock(on) { + this._shiftLock = !!on; + if (this._shiftLock) { + // Запросить pointer-lock — курсор в центре + this._requestPointerLockSafe(); + } else { + // Снять lock если он есть и нет других причин держать (first/sideview) + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' + ); + if (!needPermLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + this._applyCursorVisibility?.(); + } + isShiftLock() { return !!this._shiftLock; } + + /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc). + * Не блокирует Esc/Tab/Enter (нужны для GUI). + * Также сбрасывает накопленные клавиши чтобы движение остановилось. */ + setInputBlocked(blocked) { + this._inputBlocked = !!blocked; + if (this._inputBlocked) { + try { this._codes?.clear(); } catch (e) {} + this._shift = false; + // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры» + try { + if (document.pointerLockElement === this.canvas) document.exitPointerLock(); + } catch (e) {} + } + } + isInputBlocked() { return !!this._inputBlocked; } + + /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */ + setCameraFrozen(frozen) { + this._cameraFrozen = !!frozen; + } + isCameraFrozen() { return !!this._cameraFrozen; } + + /** Задача 04: снимок состояния камеры — для восстановления после модала. */ + captureCameraState() { + return { + yaw: this._yaw, + pitch: this._pitch, + cameraMode: this._cameraMode, + thirdDistance: this._thirdDistance, + fov: this.scene?.activeCamera?.fov, + playerPos: this._pos ? { + x: this._pos.x, y: this._pos.y, z: this._pos.z + } : null, + }; + } + + /** Задача 04: восстановить состояние камеры из снимка. */ + restoreCameraState(s) { + if (!s) return; + if (Number.isFinite(s.yaw)) this._yaw = s.yaw; + if (Number.isFinite(s.pitch)) this._pitch = s.pitch; + if (s.cameraMode) { + this._cameraMode = s.cameraMode; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance; + if (Number.isFinite(s.fov) && this.scene?.activeCamera) { + this.scene.activeCamera.fov = s.fov; + } + } + + /** Задача 04: камера-фокус на reference (cube/npc/cam-target). + * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}. + * Использует уже существующий механизм camera.focus в GameRuntime, но + * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель, + * и зум на distance. */ + focusOnTarget(ref, opts) { + opts = opts || {}; + const distance = Number.isFinite(opts.distance) ? opts.distance : 8; + const height = Number.isFinite(opts.height) ? opts.height : 3; + const fov = Number.isFinite(opts.fov) ? opts.fov : null; + let target = null; + if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) { + target = ref; + } else { + const m = this._resolveTargetMesh(ref); + if (m) { + const p = m.getAbsolutePosition?.() || m.position; + target = { x: p.x, y: p.y, z: p.z }; + } + } + if (!target) return; + // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target + // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch. + if (!this._pos) return; + const dx = target.x - this._pos.x; + const dz = target.z - this._pos.z; + const dy = target.y - this._pos.y; + const horiz = Math.hypot(dx, dz); + this._yaw = Math.atan2(dx, dz); + this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz))); + this._thirdDistance = distance; + if (this._cameraMode !== 'third') { + this._cameraMode = 'third'; + try { this._applyCameraMode?.(); } catch (e) {} + } + if (fov && this.scene?.activeCamera) { + this.scene.activeCamera.fov = fov * Math.PI / 180; + } + } + + _resolveTargetMesh(ref) { + if (!ref) return null; + if (ref.getScene && typeof ref.getScene === 'function') return ref; + const sc = this._scene3d || this.scene3d; + const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null); + if (!idStr || !sc) return null; + const tries = [ + () => sc.primitiveManager?.getMesh?.(idStr), + () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0], + () => sc.scene?.getMeshByName?.(idStr), + () => sc.npcManager?.getMeshes?.(idStr)?.[0], + ]; + for (const fn of tries) { + try { const r = fn(); if (r) return r; } catch (e) {} + } + return null; + } + + /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, d)); + // Авто-переход third↔first если пересекли порог + if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'third') { + this._cameraMode = 'first'; + this._applyCameraMode?.(); + this._requestPointerLockSafe(); + } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD + && this._cameraMode === 'first' && !this._lockFirstPerson) { + this._cameraMode = 'third'; + this._applyCameraMode?.(); + if (!this._shiftLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } + } + /** Установить границы зума колеса. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn; + if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx; + // Перекламп текущей дистанции + this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN, + Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance)); + } + /** Поведение мыши: default / lockcenter / lockcurrent. + * default — свободный курсор (стандартный browser cursor). + * lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y). + * lockcurrent — pointer-lock, но без скрытия (визуально как default, + * реально движение отслеживается через movementX/Y). + */ + setMouseBehavior(mode) { + if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return; + this._mouseBehavior = mode; + if (mode === 'default') { + // Снимаем lock если ничто другое не требует его + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (!needPermLock && document.pointerLockElement === this.canvas) { + try { document.exitPointerLock(); } catch (e) {} + } + } else { + this._requestPointerLockSafe(); + } + this._applyCursorVisibility?.(); + } + /** Видимость курсора (для third без lock). */ + setMouseIconVisible(visible) { + this._mouseIconVisible = !!visible; + this._applyCursorVisibility?.(); + } + + _setupInput() { + const canvas = this.canvas; + + const onCanvasClick = () => { + // В UI-режиме клик не перехватывает мышь. + if (this._uiCursorMode) return; + if (!this._active) return; + // Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор — + // курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем + // ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/ + // sideview/shiftLock), и только если по какой-то причине lock сняли + // (например, юзер нажал Esc в first-режиме — надо вернуть lock). + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (!needPermLock) return; + if (document.pointerLockElement !== canvas) { + try { + const p = canvas.requestPointerLock?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (e) { /* ignore */ } + } + }; + canvas.addEventListener('click', onCanvasClick); + + // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру === + // Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру. + // Отпустил → курсор вернулся на ту же позицию (браузер сам ставит). + const onCanvasMouseDownGlobal = (e) => { + if (!this._active || this._uiCursorMode) return; + if (e.button !== 2) return; // только ПКМ + // В режимах с постоянным lock'ом ПКМ ничего не делает + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) return; + // Запрашиваем lock — теперь mouseMove будет крутить камеру. + 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 onWindowMouseUpGlobal = (e) => { + if (e.button !== 2) return; + if (!this._rmbHeld) return; + this._rmbHeld = false; + // Отпускаем lock только если он был включён нами для orbit-камеры + // (т.е. сейчас НЕ режим с постоянным lock). + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) return; + if (document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) { /* ignore */ } + } + }; + canvas.addEventListener('mousedown', onCanvasMouseDownGlobal); + window.addEventListener('mouseup', onWindowMouseUpGlobal); + // Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger). + canvas.addEventListener('contextmenu', (e) => { + if (this._active) e.preventDefault(); + }); + + // === UI-режим: mousedown / mouseup → callback (для drag-игр) === + const onCanvasMouseDown = (e) => { + if (!this._uiCursorMode) return; + if (typeof this._uiMouseDownCb !== 'function') return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { + try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ } + } + }; + const onCanvasMouseUp = (e) => { + if (!this._uiCursorMode) return; + if (typeof this._uiMouseUpCb !== 'function') return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ } + }; + canvas.addEventListener('mousedown', onCanvasMouseDown); + // mouseup ловим на document — мышь могла уйти за пределы канваса + document.addEventListener('mouseup', onCanvasMouseUp); + + const onMouseMove = (e) => { + // === UI-режим: транслируем нормализованные [0..1] координаты === + // подписчику (Worker через GameRuntime). Используется для drag-игр + // типа Дальгона. + if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + // Кидаем только если внутри канваса + if (x >= 0 && x <= 1 && y >= 0 && y <= 1) { + try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ } + } + } + if (document.pointerLockElement !== canvas) return; + // Кубикон Dash: в sideview мышь не вращает камеру. + if (this._cameraMode === 'sideview') return; + // Задача 04: модал с freezeCamera — мышь не вращает. + if (this._cameraFrozen) return; + this._yaw += e.movementX * this.MOUSE_SENSITIVITY; + this._pitch += e.movementY * this.MOUSE_SENSITIVITY; + const lim = Math.PI / 2 - 0.05; + if (this._pitch > lim) this._pitch = lim; + if (this._pitch < -lim) this._pitch = -lim; + }; + document.addEventListener('mousemove', onMouseMove); + + // Колесо: zoom в third + авто-переключение third ↔ first. + // Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person + // (с pointer-lock). Колесо наружу из first → возврат в third. + const onWheel = (e) => { + if (!this._active) return; + if (this._cameraMode === 'sideview') return; + // Задача 04: модал с freezeCamera — колесо не зумит. + if (this._cameraFrozen) { e.preventDefault(); return; } + // В first-режиме колесо вверх НЕ работает (если lockfirst), вниз + // выходит обратно в third (если zoomable first, не lockfirst). + if (this._cameraMode === 'first') { + if (this._lockFirstPerson) { e.preventDefault(); return; } + if (e.deltaY > 0) { + // Колесо вниз → отдалить → переход в third + this._cameraMode = 'third'; + this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5; + this._applyCameraMode?.(); + // Снять pointer-lock — в third без shift-lock курсор виден + if (!this._shiftLock && document.pointerLockElement === canvas) { + try { document.exitPointerLock(); } catch (err) {} + } + } + e.preventDefault(); + return; + } + if (this._cameraMode !== 'third') return; + // Шаг зума — пропорционален текущей дистанции (экспоненциальный фил) + const step = Math.max(0.3, this._thirdDistance * 0.15); + this._thirdDistance += Math.sign(e.deltaY) * step; + 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) + if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) { + this._cameraMode = 'first'; + this._applyCameraMode?.(); + // Запросить pointer-lock — first всегда залочен + if (!this._shiftLock && document.pointerLockElement !== canvas) { + this._requestPointerLockSafe(); + } + } + e.preventDefault(); + }; + canvas.addEventListener('wheel', onWheel, { passive: false }); + + let wasLocked = false; + const onPointerLockChange = () => { + const locked = document.pointerLockElement === canvas; + 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) + this._rmbHeld = false; + if (this._uiCursorMode) { + this._applyCursorVisibility(); + return; + } + const needPermLock = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._cameraMode === 'sideview' || + this._shiftLock + ); + if (needPermLock) { + // Был режим с постоянным lock'ом и его сняли → Esc → выход + if (this._onExitRequest) this._onExitRequest(); + } else { + // Third-person: пользователь просто отпустил ПКМ. Курсор + // возвращается там же где был — это нормально, остаёмся в Play. + this._applyCursorVisibility(); + } + } + }; + document.addEventListener('pointerlockchange', onPointerLockChange); + + const isTypingTarget = (target) => { + if (!target) return false; + const tag = (target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + return !!target.isContentEditable; + }; + const onKeyDown = (e) => { + if (!this._active) return; + if (isTypingTarget(e.target)) return; + // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest + // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc + // в third (без pointer-lock) сразу выходил из Play. + if (e.code === 'Escape') { + if (this._onExitRequest) { + this._onExitRequest(); + return; + } + } + // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.), + // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик), + // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах). + if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') { + // Глотаем preventDefault только для игровых клавиш + if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault(); + return; + } + this._codes.add(e.code); + if (e.shiftKey) this._shift = true; + // Задача 14: в машине — V меняет камеру, E выходит. + if (this._inVehicle) { + if (e.code === 'KeyV') { this.cycleVehicleCamera(); } + else if (e.code === 'KeyE') { + const veh = this._inVehicle; + this.exitVehicle(); + if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} } + } + } + // C — переключение first/third. Отключаем в GD-режиме (автобег > 0) + // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview. + if (e.code === 'KeyC') { + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + if (!inGdMode) this._toggleCameraMode(); + } + // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег, + // поэтому переназначено на L). Курсор центрируется, корпус всегда + // лицом к камере, камера через плечо. + if (e.code === 'KeyL') { + this.setShiftLock(!this._shiftLock); + } + // B — встроенный магазин скинов (задача 07). Открывается только если + // включён в проекте (scene.skins.shopVisible). Toggle. + if (e.code === 'KeyB' && !this._inputBlocked) { + try { this._scene3d?.toggleSkinShop?.(); } catch (err) {} + } + // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play) + if (e.code === 'Tab') { + e.preventDefault(); + this.setUiCursorMode(!this._uiCursorMode); + } + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { + e.preventDefault(); + } + // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус), + // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек. + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) { + e.preventDefault(); + } + }; + const onKeyUp = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.delete(e.code); + if (!e.shiftKey) this._shift = false; + }; + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + const onBlur = () => { + this._codes.clear(); + this._shift = false; + }; + window.addEventListener('blur', onBlur); + + this._listeners = [ + { target: canvas, type: 'click', fn: onCanvasClick }, + { target: canvas, type: 'wheel', fn: onWheel, + opts: { passive: false } }, + { target: document, type: 'mousemove', fn: onMouseMove }, + { target: document, type: 'pointerlockchange', fn: onPointerLockChange }, + { target: window, type: 'keydown', fn: onKeyDown }, + { target: window, type: 'keyup', fn: onKeyUp }, + { target: window, type: 'blur', fn: onBlur }, + ]; + } + + _tick() { + // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая + // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж + // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1 + // (max 10 кадров/сек физики). Этого хватает чтобы движение было + // плавным даже на 5 FPS — просто чуть рывками. + let dt = this.scene.getEngine().getDeltaTime() / 1000; + if (dt <= 0) return; + if (dt > 0.1) dt = 0.1; + + // === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу === + if (this._inVehicle) { + try { this._tickVehicle(dt); } catch (e) { /* ignore */ } + return; + } + + // === Присед: по Ctrl на десктопе, или через мобильную кнопку + // (которая шлёт keydown 'ControlLeft'). C — НЕ используется + // (это смена вида в Babylon). + // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику + // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship). + const inGdMode = (this._autoRunSpeed || 0) > 0 + || this._shipMode || this._ufoMode || this._waveMode || this._robotMode; + const wantCrouch = !inGdMode && this._codes + && (this._codes.has('ControlLeft') || this._codes.has('ControlRight')); + if (wantCrouch && !this._crouching) { + this._crouching = true; + // сдвигаем центр капсулы вниз — низ ног остаётся на земле + const dH = this.HALF_H_CROUCH - this.HALF_H; + this.HALF_H = this.HALF_H_CROUCH; + if (this._pos) this._pos.y += dH; + } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) { + this._crouching = false; + const dH = this.HALF_H_NORMAL - this.HALF_H; + this.HALF_H = this.HALF_H_NORMAL; + if (this._pos) this._pos.y += dH; + } + + // === Горизонтальное движение === + const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw)); + const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw)); + const isSprinting = this._shift; + const speedMult = isSprinting ? this.SPRINT_MULT : 1; + const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt; + + let moveX = 0, moveZ = 0; + // c (codes) используется ниже для прыжка/Space — объявляем здесь, + // чтобы был доступен после if/else движения. + const c = this._codes; + const am = this._analogMove; + // === Кубикон Dash: авто-движение по +X, ввод заблокирован === + // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме + // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются. + if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) { + moveX = this._autoRunSpeed * dt; + moveZ = 0; + } else + // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z === + // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит + // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО — + // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал + // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос + // делает скрипт через player.teleport по X. + if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) { + moveX = 0; + moveZ = this._autoRunSpeed * dt; + } else + // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами: + // позволяет двигаться в любом направлении плавно, не только в 8 секторов. + if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) { + // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*). + // *На практике мы передаём sprint через setVirtualShift. + const mag = Math.min(1, Math.hypot(am.x, am.y)); + // Нормализуем направление, скорость = mag * speed + const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y)); + const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y)); + const v = mag * speed; + // y=1 → вперёд (forward), x=1 → вправо (right) + moveX = forward.x * dirY * v + right.x * dirX * v; + moveZ = forward.z * dirY * v + right.z * dirX * v; + } else { + if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; } + if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; } + if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; } + if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; } + } + + const isMoving = (moveX !== 0 || moveZ !== 0); + + // === ЛЁД: инерция движения === + // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость + // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр) + // с коэффициентом ускорения, и затухает с (1 - friction*dt*8). + if (this._iceFriction > 0.001) { + const fric = Math.min(1, this._iceFriction); + // Чем выше friction, тем медленнее набираем скорость и тем дольше + // затухает после отпускания клавиш. + const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45 + const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1 + // Целевая скорость = moveX/moveZ (уже содержит dt) + // Прибавляем разницу с ускорением + this._iceVelX += (moveX - this._iceVelX) * accel; + this._iceVelZ += (moveZ - this._iceVelZ) * accel; + if (!isMoving) { + this._iceVelX *= decay; + this._iceVelZ *= decay; + if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0; + if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0; + } + moveX = this._iceVelX; + moveZ = this._iceVelZ; + } else { + // Сбрасываем накопленную скорость когда лёд выкл + this._iceVelX = 0; + this._iceVelZ = 0; + } + + // === Плавание (если AABB пересекает блок-воду) === + const inWater = this._isInWater(); + const submerged = this._isSubmerged(); + if (inWater) { + // В воде движемся в 2 раза медленнее + moveX *= 0.5; + moveZ *= 0.5; + } + + // === Вертикальное === + if (inWater) { + // Плавание: лёгкая гравитация + плавучесть к поверхности + const buoyancy = submerged ? 6 : 0; + const swimGravity = -3; + this._vy += (buoyancy + swimGravity) * dt; + this._vy *= Math.max(0, 1 - 3 * dt); + if (this._codes.has('Space')) this._vy += 14 * dt; + if (this._vy > 4) this._vy = 4; + if (this._vy < -4) this._vy = -4; + } else { + // Кубикон Dash: умеренная усиленная гравитация для коротких + // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с + // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2, + // не слишком улетает по X (~4-5м за прыжок). + // Variable-jump (отпускание Space обрезает vy) НЕ используется — + // в авто-беге игрок не контролирует длительность нажатия. + // + // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ + // к потолку. Кап-скорости тоже инвертируется. + const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0; + const userGravityMul = this._gravityMul || 1; + const gDir = this._gravityDir || 1; + + if (this._waveMode) { + // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|). + // Гравитация полностью игнорируется — линейное движение по диагонали. + const speed = Math.max(1, this._autoRunSpeed || 8); + this._vy = this._jumpHeld ? speed : -speed; + } else { + this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt; + + // SHIP-режим: при удержании Space даём импульс ВВЕРХ + // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс + // делает плавный полёт. + if (this._shipMode && this._jumpHeld) { + // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался, + // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36) + const SHIP_THRUST = 80; // м/с² против гравитации + this._vy += SHIP_THRUST * gDir * dt; + } + // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем + // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается. + if (this._robotMode && this._robotBoostLeft > 0) { + if (c.has('Space')) { + // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно. + // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену). + this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt; + this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt); + } else { + this._robotBoostLeft = 0; + } + } + + // Cap: ±50 в любом направлении (для Ship — мягче, ±25) + const vyCap = this._shipMode ? 25 : 50; + if (this._vy < -vyCap) this._vy = -vyCap; + if (this._vy > vyCap) this._vy = vyCap; + } + } + + const beforeX = this._pos.x, beforeZ = this._pos.z; + + // === STICK к движущейся платформе === + // Если в прошлом кадре стояли на примитиве/модели, и она двигается — + // двигаем игрока вместе с ней (по дельте позиции платформы за кадр). + if (this._lastGroundData && this._lastGroundPos) { + const gd = this._lastGroundData; + // Текущая позиция платформы — берём из live-data (она обновляется + // movingPlatforms-скриптом через scene.move). + const curX = gd.x, curY = gd.y, curZ = gd.z; + const dPlatX = curX - this._lastGroundPos.x; + const dPlatY = curY - this._lastGroundPos.y; + const dPlatZ = curZ - this._lastGroundPos.z; + // Применяем дельту только если она разумная (защита от телепорта + // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.) + if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) { + this._pos.x += dPlatX; + this._pos.y += dPlatY; + this._pos.z += dPlatZ; + } + } + + // PERF-METRICS: замер физики игрока + const _pt0 = performance.now(); + const result = this.physics.moveAABB( + this._pos, this.HALF_W, this.HALF_H, this.HALF_D, + moveX, this._vy * dt, moveZ + ); + const _bs = this._scene3d || this.scene3d; + if (_bs && _bs._perfMetrics) { + _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0; + _bs._perfMetrics.physics_count++; + } + this._pos.set(result.x, result.y, result.z); + if (result.hitY) this._vy = 0; + // Surface-follow на smooth-terrain прижал нас к склону — гравитация + // больше не нужна на этом кадре, иначе будет вибрация от соревнования + // (гравитация тянет вниз → следующий кадр surface поднимает обратно). + if (result.surfaceFollowed) this._vy = 0; + + // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы + // визуально плавно подняться. Физика уже сделала телепорт вверх, + // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже + // в кадровой логике (раз за tick). + if (result.steppedUpBy && result.steppedUpBy > 0) { + this._stepUpVisualOffset += result.steppedUpBy; + // Кэп — не более 1.2м, иначе при множественных степах подряд + // оффсет может стать гигантским и аватар «уйдёт под землю». + if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2; + } + // Спадание оффсета. dt — реальное время тика (секунды). + if (this._stepUpVisualOffset > 0) { + this._stepUpVisualOffset -= this._stepUpDecay * dt; + if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0; + } + + // Запоминаем «на чём стоим» для следующего кадра + if (result.onGround && result.groundData?.data) { + this._lastGroundData = result.groundData.data; + this._lastGroundPos = { + x: result.groundData.data.x, + y: result.groundData.data.y, + z: result.groundData.data.z, + }; + } else { + this._lastGroundData = null; + this._lastGroundPos = null; + } + + // === Авто-вылезание на берег из воды === + // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться — + // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание». + if (inWater && isMoving && (result.hitX || result.hitZ)) { + this._vy = Math.max(this._vy, 5); + } + + // Респавн если игрок упал в пустоту (за пределы baseplate) + if (this._pos.y < -30) { + const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 }; + this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z); + this._vy = 0; + } + + // === Push unanchored объектов при пересечении === + // Скорость игрока в этом кадре (для направления толчка). + // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0, + // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD) + // и forward камеры — DynamicsManager выберет лучшее направление. + const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt); + const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt); + const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ); + const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal); + // Берём "желаемое" движение если оно больше реального (игрок упёрся, + // но WASD нажаты — значит "пытается толкать"). + const useDesired = desiredSpeed > realSpeed + 0.5; + const pushVx = useDesired ? moveX / dt : playerVxReal; + const pushVz = useDesired ? moveZ / dt : playerVzReal; + if (this._scene3d?.dynamics?.isEnabled?.()) { + this._scene3d.dynamics.applyPushFromPlayer( + this._pos.x, this._pos.y, this._pos.z, + this.HALF_W, this.HALF_H, this.HALF_D, + pushVx, pushVz, + forward.x, forward.z, + playerVxReal, playerVzReal + ); + } + + // === Чекпоинты — обновить точку спавна при касании === + if (this.physics?.getOverlappingPrimitives) { + const overlaps = this.physics.getOverlappingPrimitives( + this._pos.x, this._pos.y, this._pos.z, + this.HALF_W, this.HALF_H, this.HALF_D + ); + for (const data of overlaps) { + if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) { + this._activatedCheckpoints.add(data.id); + if (this._scene3d) { + // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта + // чтобы при респавне игрок не попал внутрь чекпоинта. + this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z); + } + this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь») + } + } + } + + // Шаги — копим пройденную горизонтальную дистанцию когда onGround + if (result.onGround && isMoving) { + const dxReal = this._pos.x - beforeX; + const dzReal = this._pos.z - beforeZ; + this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal); + const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK; + if (this._distanceSinceLastStep >= stepThreshold) { + this._distanceSinceLastStep = 0; + this._playFootstep(); + } + } else { + // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки + // не воспроизвёлся слишком рано. + this._distanceSinceLastStep = 0; + } + + // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть. + // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже + // края, понимает что упал в яму, бесит. С коянот-окном — прощает. + // + // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом», + // используем result.onCeiling вместо onGround. + const gDir = this._gravityDir || 1; + const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false); + if (effGround) { + this._coyoteLeft = 0.12; + } else if (this._coyoteLeft > 0) { + this._coyoteLeft -= dt; + } + const canJump = effGround || this._coyoteLeft > 0; + // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде. + // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации). + // В Ship-режиме обычный jump-импульс отключён — корабль управляется + // только удержанием Space (см. блок _shipMode выше). _jumpHeld + // обновляем чтобы остальная логика (release, double-jump) работала. + if (this._waveMode) { + // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block). + // _jumpHeld для синхронизации с состоянием Space. + this._jumpHeld = c.has('Space'); + } else + if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) { + if (!this._jumpHeld) { + // Robot — стартовый импульс полный (как куб) для тапа достаточный, + // boost-фаза 0.45с удлиняет подъём при удержании Space. + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; + this._playJumpSound(); + this._jumpHeld = true; + this._coyoteLeft = 0; + // Robot: запускаем boost-фазу на 0.45с + if (this._robotMode) { + this._robotBoostLeft = 0.45; + } + } + } else if (this._shipMode && c.has('Space')) { + this._jumpHeld = true; + } else if (this._ufoMode && c.has('Space') && !inWater) { + // UFO: каждый отдельный тап = микропрыжок (даже в воздухе). + // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата. + if (!this._jumpHeld) { + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85; + this._playJumpSound(); + this._jumpHeld = true; + } + } + // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка + if (effGround) this._doubleJumpUsed = false; + // Двойной прыжок: в воздухе и Space нажат отдельно (после release). + if (!effGround && !inWater && c.has('Space') + && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) { + this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir; + this._playJumpSound(); + this._doubleJumpUsed = true; + this._jumpHeld = true; + } + if (!c.has('Space')) this._jumpHeld = false; + + // Звук всплеска при входе/выходе из воды + if (this._wasInWater !== inWater) { + this._playSplashSound(); + this._wasInWater = inWater; + } + + // Состояние игрока для game.player.state ('ground'|'air'|'water'). + this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air'); + // Хук game.onPlayerLand — был в воздухе, коснулся земли. + if (effGround && this._wasOnGround === false && !inWater) { + if (typeof this._onLand === 'function') { + try { this._onLand(); } catch (e) { /* ignore */ } + } + } + this._wasOnGround = effGround; + + // === Камера === + this.camera.position = this._computeCameraPos(); + // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную. + if (this._cameraOverride) { + this._applyCameraOverride(dt); + } + // Camera shake — небольшое случайное смещение по X/Y, затухает. + if (this._cameraShakeLeft > 0) { + this._cameraShakeLeft -= dt; + const t = Math.max(0, this._cameraShakeLeft); + const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка + this.camera.position.x += (Math.random() - 0.5) * amp; + this.camera.position.y += (Math.random() - 0.5) * amp; + } + // Скрипт-управление камерой (cutscene/focus) уже выставило И + // позицию, И направление взгляда через setTarget. НЕ трогаем + // camera.rotation — иначе setTarget затирается и катсцена + // «смотрит прямо» вместо вращения по точкам lookAt. + if (!this._cameraOverride) { + if (this._cameraMode === 'front') { + // Камера смотрит назад на игрока — yaw на 180°, pitch инверт. + this.camera.rotation.x = -this._pitch; + this.camera.rotation.y = this._yaw + Math.PI; + } else if (this._cameraMode === 'sideview') { + // Sideview: камера в -Z от игрока, смотрит на +Z. + this.camera.rotation.x = 0; + this.camera.rotation.y = 0; + this.camera.rotation.z = 0; + } else { + this.camera.rotation.x = this._pitch; + this.camera.rotation.y = this._yaw; + } + if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0; + } + + // === Модель игрока === + if (this._modelRoot) { + // === Поза: в воде персонаж лежит горизонтально (плавает) === + // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция. + const targetSwimTilt = inWater ? Math.PI / 2 : 0; + const swimTilt = this._swimTilt ?? 0; + const tiltStep = 4 * dt; // 4 рад/с скорость наклона + let nextTilt = swimTilt; + if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) { + nextTilt = targetSwimTilt; + } else { + nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep; + } + this._swimTilt = nextTilt; + + // В воде наклон 90° кладёт модель горизонтально. yLift поднимает + // root к центру AABB. Также при наклоне корень оказывается в позиции + // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root + // на длину модели вперёд по yaw чтобы голова была впереди. + const tiltFrac = nextTilt / (Math.PI / 2); // 0..1 + const yLift = inWater ? this.HALF_H * tiltFrac : 0; + const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела + const fwdShift = inWater ? bodyLen * tiltFrac : 0; + const fx = Math.sin(this._modelYaw); + const fz = Math.cos(this._modelYaw); + this._modelRoot.position.set( + this._pos.x + fx * fwdShift, + this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset, + this._pos.z + fz * fwdShift + ); + + // Поворот модели: + // - на суше: направление РЕАЛЬНОГО движения (как было). + // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто + // двигает тело вбок без вращения, как на суше при first-person. + if (inWater) { + const targetYaw = this._yaw; + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt * 2; + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } else { + // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно + // следует за yaw камеры (AutoRotate привязан к камере). + // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения. + const followCamera = ( + this._cameraMode === 'first' || + this._cameraMode === 'lockfirst' || + this._shiftLock + ); + if (followCamera) { + const targetYaw = this._yaw; + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } else { + const dxReal = this._pos.x - beforeX; + const dzReal = this._pos.z - beforeZ; + const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001; + if (movedHorizontal) { + const targetYaw = Math.atan2(dxReal, dzReal); + let diff = targetYaw - this._modelYaw; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + const maxStep = this.MODEL_TURN_SPEED * dt; + if (Math.abs(diff) <= maxStep) { + this._modelYaw = targetYaw; + } else { + this._modelYaw += Math.sign(diff) * maxStep; + } + } + } + } + // Применяем yaw + swim-tilt. + // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит + // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift). + this._modelRoot.rotation.y = this._modelYaw; + this._modelRoot.rotation.x = nextTilt; + // В воде также добавляем лёгкое покачивание по Z (как волна тела) + if (inWater) { + const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15; + this._modelRoot.rotation.z = wobble; + } else if (this._cameraMode === 'sideview') { + // Кубикон Dash: в воздухе куб крутится назад (по часовой если + // смотреть с -Z), на земле плавно возвращается в 0. + // Скорость подобрана так чтобы между прыжками куб успевал + // совершить ~1 оборот. + const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с + if (!result.onGround) { + this._dashSpinAngle -= SPIN_SPEED * dt; + } else { + // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0) + const TAU = Math.PI * 2; + const target = Math.round(this._dashSpinAngle / TAU) * TAU; + const diff = target - this._dashSpinAngle; + const snapStep = SPIN_SPEED * 1.5 * dt; + if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target; + else this._dashSpinAngle += Math.sign(diff) * snapStep; + } + this._modelRoot.rotation.z = this._dashSpinAngle; + } else { + this._modelRoot.rotation.z = 0; + } + // Поза с оружием — обновляем флаг каждый кадр (на случай смены) + const hasWeapon = !!this._scene3d?.weapons?._equipped; + this._updateExtendedArm(hasWeapon); + // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций. + // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем + // и пишем свои углы Эйлера. Это ключевой момент: вызывается + // каждый кадр, поэтому override переживает анимацию. + this._applyMeshRotationOverrides(); + } + + // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)). + // Применяем каждый кадр — на случай если меши только что асинхронно + // загрузились, либо _applyCameraMode перезаписал enabled=true. + if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) { + for (let i = 0; i < this._modelMeshes.length; i++) { + const m = this._modelMeshes[i]; + if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { + try { m.setEnabled(false); } catch (e) {} + } + } + } + + // Тик распадающихся кусков игрока (после смерти) + this._tickDebris(dt); + + // === Анимации === + // Снимок скорости/опоры для процедурной анимации non-humanoid скинов. + this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1); + this._isGrounded = !!result.onGround; + + // Non-humanoid single-mesh скин: костей нет — анимируем процедурно + // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них. + if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) { + this._animateNonHumanoidMesh(dt); + return; + } + + // R15-скин: процедурный аниматор (нет glTF AnimationGroups). + // Состояния: idle/walk/run/jump/fall. sprint → run. + if (this._isR15 && this._r15Animator) { + let r15State; + if (!result.onGround) { + // vy > 0 — вверх (jump-поза с поджатыми ногами), + // vy < 0 — вниз (fall, лёгкий наклон корпуса). + r15State = (this._vy > 0.5) ? 'jump' : 'fall'; + } else if (inWater) { + r15State = isMoving ? 'walk' : 'idle'; + } else if (isMoving) { + r15State = isSprinting ? 'run' : 'walk'; + } else { + r15State = 'idle'; + } + this._r15Animator.setState(r15State); + this._r15Animator.update(dt); + // Override костей поверх анимации (поза руки с оружием/замах). + this._applyR15BoneOverrides(); + return; // R15 не использует AnimationGroups-путь ниже + } + + let nextAnim; + if (inWater) { + // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе + nextAnim = isMoving ? 'walk' : 'idle'; + } else if (!result.onGround) { + nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle'); + } else if (isMoving) { + nextAnim = isSprinting ? 'sprint' : 'walk'; + } else { + nextAnim = 'idle'; + } + // Снимок состояния для лога в _playAnim + this._lastAnimDebug = { + vy: this._vy, + og: !!result.onGround, + sf: !!result.surfaceFollowed, + mv: !!isMoving, + }; + // === Периодический trace состояния (раз в 30 кадров ~0.5с) === + // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация + // не меняется — полезно для разбора "вибрации". + if (!this._animTraceCnt) this._animTraceCnt = 0; + this._animTraceCnt++; + if (this._animTraceCnt >= 30) { + this._animTraceCnt = 0; + const d = this._lastAnimDebug; + console.log(`[AnimTrace] anim=${this._currentAnim} ` + + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`); + } + this._playAnim(nextAnim); + } +} diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index b82d650..a2afaba 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -79,6 +79,8 @@ let _playerJoinHandlers = []; let _playerLeaveHandlers = []; // Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). let _cutsceneDoneHandlers = []; +let _vehicleEnterHandlers = []; // задача 14 +let _vehicleExitHandlers = []; let _mpMessageHandlers = {}; // name → [fn] let _remoteHandlers = {}; // Phase 6.6: RemoteEvent handlers, name → [fn] // Подписки game.room.onChange(key, fn): key → [fn]. @@ -109,7 +111,7 @@ let _invUiSlotClickHandlers = []; const _instTouchHandlers = new Map(); function _instHandlerBucket(ref) { let b = _instTouchHandlers.get(ref); - if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } + if (!b) { b = { touch: [], untouch: [], click: [], interact: [] }; _instTouchHandlers.set(ref, b); } return b; } // Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') @@ -512,6 +514,18 @@ function _getOrCreateInstance(ref, kindHint) { _instHandlerBucket(ref).click.push(fn); _send('inst.watchClick', { ref }); }; + // Задача 14: findOne(ref).onInteract(fn, {text,distance,key,holdDuration}) + if (prop === 'onInteract') return (fn, opts) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).interact.push(fn); + _send('inst.registerInteract', { + ref, + text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', + distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, + key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', + holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0, + }); + }; return undefined; }, @@ -718,6 +732,8 @@ function _buildSelfApi() { text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', + // Задача 14: hold-to-action — сколько секунд держать клавишу (0=мгновенно). + holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0, }); }, move(x, y, z) { @@ -1344,6 +1360,14 @@ const game = { onCutsceneDone(fn) { if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); }, + /** Задача 14: игрок сел в машину. fn(vehicleRef). */ + onVehicleEnter(fn) { + if (typeof fn === 'function') _vehicleEnterHandlers.push(fn); + }, + /** Задача 14: игрок вышел из машины. fn(vehicleRef). */ + onVehicleExit(fn) { + if (typeof fn === 'function') _vehicleExitHandlers.push(fn); + }, /** Игрок покинул комнату. fn({sessionId, name}). */ onPlayerLeave(fn) { if (typeof fn === 'function') _playerLeaveHandlers.push(fn); @@ -1584,6 +1608,18 @@ const game = { if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); return ref; } + // Задача 14: машина. type = 'vehicle:car' (subType сейчас не важен). + if (kind === 'vehicle') { + _localRefSeq++; + const ref = 'vehicle:_local_' + _localRefSeq; + _send('scene.spawn', { + kind: 'vehicle', subType, + model: opts.model || 'car-sedan', color: opts.color, name: opts.name, + params: opts.params || {}, + x, y, z, rotationY: opts.rotationY || 0, ref, + }); + return _getOrCreateInstance(ref, 'vehicle') || ref; + } return null; }, /** Удалить объект по ref. */ @@ -4191,6 +4227,10 @@ self.onmessage = (e) => { const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; for (const fn of list) _safeCall(fn, payload, 'inst.' + t); } + } else if (t === 'instInteract') { + // Задача 14: взаимодействие (F) с объектом по findOne(ref).onInteract. + const b = _instTouchHandlers.get(payload && payload.ref); + if (b) for (const fn of b.interact) _safeCall(fn, payload, 'inst.onInteract'); } else if (t === 'hpChange') { for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); } else if (t === 'mobKilled') { @@ -4238,6 +4278,13 @@ self.onmessage = (e) => { } else if (t === 'cutsceneDone') { // Катсцена камеры завершилась (Фаза 5.7). for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); + } else if (t === 'vehicleEnter') { + // Задача 14: игрок сел в машину. payload: { vehicleId }. + const vref = 'vehicle:' + (payload && payload.vehicleId); + for (const fn of _vehicleEnterHandlers) _safeCall(fn, vref, 'onVehicleEnter'); + } else if (t === 'vehicleExit') { + const vref = 'vehicle:' + (payload && payload.vehicleId); + for (const fn of _vehicleExitHandlers) _safeCall(fn, vref, 'onVehicleExit'); } else if (t === 'playerJoin') { // payload: { sessionId, name } for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); diff --git a/src/editor/engine/VehicleHud.js b/src/editor/engine/VehicleHud.js new file mode 100644 index 0000000..adcaf0f --- /dev/null +++ b/src/editor/engine/VehicleHud.js @@ -0,0 +1,95 @@ +/** + * VehicleHud — HUD водителя (задача 14): круглый спидометр со стрелкой, + * передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как + * ShopInventoryUi). Показывается пока игрок за рулём. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ +export class VehicleHud { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this.needle = null; + this.speedText = null; + this.gearText = null; + this._maxKmh = 80; + } + + show(maxKmh) { + this.remove(); + this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10); + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const root = document.createElement('div'); + root.className = 'kbn-veh-hud'; + root.style.cssText = + 'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' + + 'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;'; + + // SVG-циферблат. + const R = 70, CX = 80, CY = 80; + const startA = 135, endA = 405; // дуга 270° + const ticks = []; + const N = 8; + for (let i = 0; i <= N; i++) { + const a = (startA + (endA - startA) * i / N) * Math.PI / 180; + const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4); + const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14); + ticks.push(``); + const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4; + const val = Math.round(this._maxKmh * i / N); + ticks.push(`${val}`); + } + root.innerHTML = + `` + + `` + + ticks.join('') + + `` + + `` + + `0` + + `км/ч` + + `N` + + ``; + parent.appendChild(root); + this.root = root; + this.needle = root.querySelector('#kbn-veh-needle'); + this.speedText = root.querySelector('#kbn-veh-speed'); + this.gearText = root.querySelector('#kbn-veh-gear'); + this._CX = CX; this._CY = CY; + + // Подсказки клавиш справа снизу. + const keys = document.createElement('div'); + keys.className = 'kbn-veh-keys'; + keys.style.cssText = + 'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' + + 'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' + + 'text-shadow:0 1px 3px rgba(0,0,0,0.7);'; + keys.innerHTML = '
WASD — руль
V — камера
E — выйти
'; + parent.appendChild(keys); + this._keys = keys; + } + + /** Обновить стрелку/число/передачу. speed — м/с (signed). */ + update(speedMs) { + if (!this.needle) return; + const kmh = Math.abs(speedMs) * 3.6; + const frac = Math.max(0, Math.min(1, kmh / this._maxKmh)); + const ang = -135 + 270 * frac; // -135°..+135° + this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`); + if (this.speedText) this.speedText.textContent = String(Math.round(kmh)); + if (this.gearText) { + const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D'); + this.gearText.textContent = g; + this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0'); + } + } + + remove() { + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } + if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; } + this.needle = this.speedText = this.gearText = null; + } + + dispose() { this.remove(); } +} diff --git a/src/editor/engine/VehicleManager.js b/src/editor/engine/VehicleManager.js new file mode 100644 index 0000000..aabbf04 --- /dev/null +++ b/src/editor/engine/VehicleManager.js @@ -0,0 +1,251 @@ +import { Vector3, TransformNode } from '@babylonjs/core'; + +/** + * VehicleManager — система транспорта (задача 14, фаза V1 аркадная + V2 параметры). + * + * Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) + + * 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ: + * speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer + * (масштаб от скорости — нет вращения на месте); коллизия с миром через + * physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с + * другими машинами НЕ сталкиваются (V1) — только chassis с миром. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +const DEFAULT_PARAMS = { + mass: 1200, + enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с. + maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров + turnSpeed: 1.8, // рад/с при полной скорости + brake: 26, // замедление при тормозе/реверсе + drive: 'rwd', +}; + +export class VehicleManager { + constructor(scene3d) { + this.s = scene3d; + this.scene = scene3d.scene; + this.vehicles = new Map(); // id → veh + this._seq = 0; + } + + get _physics() { return this.s.physics; } + get _models() { return this.s.modelManager; } + + /** + * Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }. + * Возвращает Promise. + */ + async spawn(opts) { + opts = opts || {}; + const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0; + // Идемпотентность: если машина с такой позицией уже есть — не плодим + // (защита от двойного выполнения скрипта спавна → дубли машин). + for (const v of this.vehicles.values()) { + if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id; + } + const id = ++this._seq; + const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0; + const yaw = Number(opts.rotationY) || 0; + const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) }; + const modelType = opts.model || 'car-sedan'; + + // chassis-узел — родитель кузова и колёс. + const chassisNode = new TransformNode(`vehicle_${id}`, this.scene); + chassisNode.position = new Vector3(x, y, z); + chassisNode.rotation = new Vector3(0, yaw, 0); + + const veh = { + id, name: opts.name || 'Машина', params, + spawnX: x, spawnZ: z, // для дедупа повторного спавна + chassisNode, bodyInstanceId: null, wheels: [], + pos: new Vector3(x, y, z), yaw, vy: 0, + speed: 0, steerAngle: 0, + half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова + throttle: 0, steer: 0, handbrake: false, + driver: null, + handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] }, + ref: opts.ref || null, + }; + this.vehicles.set(id, veh); + + // Кузов (GLB Kenney car-kit). + try { + const bodyId = await this._models.addInstance(modelType, x, y, z, yaw); + veh.bodyInstanceId = bodyId; + const inst = this._models.instances.get(bodyId); + if (inst && inst.rootMesh) { + // Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга + // (в мировых координатах, кузов ещё в (x,y,z)). + try { + const bb = inst.rootMesh.getHierarchyBoundingVectors(true); + veh.half = { + w: Math.max(0.6, (bb.max.x - bb.min.x) / 2), + h: Math.max(0.4, (bb.max.y - bb.min.y) / 2), + d: Math.max(1.0, (bb.max.z - bb.min.z) / 2), + }; + // Насколько низ кузова ниже точки спавна y — чтобы посадить + // кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле, + // не парит). bodyYOffset применяется к локальной Y кузова. + veh.bodyYOffset = -(bb.min.y - y) - veh.half.h; + } catch (e) { veh.bodyYOffset = -veh.half.h; } + inst.rootMesh.setParent(chassisNode); + inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0); + inst.rootMesh.rotation = Vector3.Zero(); + // Цвет кузова (tint поверх GLB-текстуры). + if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} } + } + } catch (e) { console.warn('[VehicleManager] body load failed', e); } + + // Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат + // колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1). + // Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно). + + // «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она + // висит/утоплена на стартовой y, пока никто не за рулём (нет tick). + this._settle(veh); + // Повторное оседание на следующих кадрах: физический грид статики может + // ещё не проиндексироваться к моменту спавна (await addInstance), тогда + // первый _settle не находит пол и машина зависает в воздухе (баг седана). + for (const d of [120, 350, 800]) { + setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d); + } + + return id; + } + + /** + * Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и + * роняем большим запасом (много шагов), чтобы гарантированно найти пол даже + * если стартовая y оказалась чуть ниже/выше или физика поздно готова. + */ + _settle(veh) { + try { + // Поднимем чуть вверх и роняем с запасом (до ~20 ед. вниз). + veh.pos.y += 0.5; + let landed = false; + for (let i = 0; i < 80; i++) { + const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0); + veh.pos.set(r.x, r.y, r.z); + if (r.hitY) { landed = true; break; } + } + // Микро-дожатие: пока земля прямо под низом — подвинуть вплотную. + if (landed) { + for (let i = 0; i < 4; i++) { + const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0); + veh.pos.set(r.x, r.y, r.z); + if (r.hitY) break; + } + } + veh.vy = 0; + veh.chassisNode.position.copyFrom(veh.pos); + veh.chassisNode.rotation.y = veh.yaw; + } catch (e) { /* ignore */ } + } + + getById(id) { return this.vehicles.get(id) || null; } + + /** Установить ввод водителя (из PlayerController). */ + setInput(veh, throttle, steer, handbrake) { + if (!veh) return; + veh.throttle = Math.max(-1, Math.min(1, throttle || 0)); + veh.steer = Math.max(-1, Math.min(1, steer || 0)); + veh.handbrake = !!handbrake; + } + + /** Физический шаг машины (вызывается каждый кадр пока есть водитель). */ + tickVehicle(veh, dt) { + if (!veh) return; + dt = Math.min(dt, 1 / 30); + const p = veh.params; + const prevSpeed = veh.speed; + + // Ускорение / торможение / реверс. + if (veh.throttle > 0) { + veh.speed += veh.throttle * p.enginePower * dt; + } else if (veh.throttle < 0) { + // S: сначала тормоз, потом задний ход (ограничен). + if (veh.speed > 0.2) veh.speed -= p.brake * dt; + else veh.speed += veh.throttle * p.enginePower * 0.5 * dt; + } + // Накат-трение. + veh.speed *= (1 - 1.2 * dt); + if (veh.handbrake) veh.speed *= (1 - 6 * dt); + // Клампы. + const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4; + if (veh.speed > maxFwd) veh.speed = maxFwd; + if (veh.speed < -maxRev) veh.speed = -maxRev; + if (Math.abs(veh.speed) < 0.05) veh.speed = 0; + + // Поворот (зависит от скорости — нельзя крутиться на месте). + const speedFrac = veh.speed / maxFwd; + veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt; + // Угол доворота передних колёс (визуал) — плавный lerp. + const targetSteer = veh.steer * 0.5; + veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8); + + // Направление и перемещение. + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const moveX = dir.x * veh.speed * dt; + const moveZ = dir.z * veh.speed * dt; + // Гравитация (машина сидит на полу/дороге). + veh.vy += -22 * dt; + + // Коллизия с миром через тот же солвер что у игрока. + let res; + try { + res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ); + } catch (e) { + res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false }; + } + veh.pos.set(res.x, res.y, res.z); + if (res.hitY) veh.vy = 0; + // Удар об стену — гасим ход. + if (res.hitX || res.hitZ) { + const force = Math.abs(veh.speed); + veh.speed *= 0.3; + for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} } + } + + // Применить к узлам. + veh.chassisNode.position.copyFrom(veh.pos); + veh.chassisNode.rotation.y = veh.yaw; + // Колёса: передние доворачивают, все катятся. + const roll = (veh.speed * dt) / 0.4; + for (const w of veh.wheels) { + if (w.isFront) w.node.rotation.y = veh.steerAngle; + w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2); + } + + if (Math.abs(veh.speed - prevSpeed) > 0.01) { + for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} } + } + // Падение в бездну — сигнал PlayerController высадить + респавн. + if (veh.pos.y < -25) return { fellOut: true }; + return null; + } + + /** Текущая скорость машины в м/с (для спидометра). */ + speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; } + + applyImpulse(veh, v) { + if (!veh || !v) return; + // Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению. + if (Number.isFinite(v.y)) veh.vy += Number(v.y); + const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw)); + const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z; + veh.speed += horiz; + } + + dispose() { + for (const veh of this.vehicles.values()) { + try { + if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId); + for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId); + veh.chassisNode?.dispose?.(); + } catch (e) { /* ignore */ } + } + this.vehicles.clear(); + } +} diff --git a/src/preview-player/KubikonPlayer.jsx b/src/preview-player/KubikonPlayer.jsx index 58ba7fb..4b85d34 100644 --- a/src/preview-player/KubikonPlayer.jsx +++ b/src/preview-player/KubikonPlayer.jsx @@ -465,8 +465,9 @@ const KubikonPlayer = () => { try { scene.setPlayerModelType?.(mySkin); } catch (e) {} setLoading(false); - // Засчитываем плей - Kubikon3DApi.incrementPlay(projectId).catch(() => {}); + // Засчитываем плей. Передаём user_id (если залогинен) — + // активирует self-cooldown и user-cooldown на бэке. + Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {}); // Запускаем игру сразу setTimeout(() => { scene.enterPlayMode?.();