/** * 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'; // Подфаза 3.2/3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md import { AccessoryManager } from './AccessoryManager'; // Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом). // '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; // Строит абсолютный URL для /api-storys. // Использует VITE_API_BASE если задан (предпочтительно когда плеер и API // на разных доменах), иначе fallback: // - пустой base в dev (vite-proxy роутит сам) // - текущий origin в prod (предполагаем что API на том же домене) function _storysApiUrl(path) { const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; if (env.VITE_API_BASE) { return env.VITE_API_BASE + '/api-storys' + path; } const isDev = (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')); const base = isDev ? '' : (typeof window !== 'undefined' ? window.location.origin : ''); return base + '/api-storys' + path; } 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 this.THIRD_DISTANCE_MIN = 2.5; this.THIRD_DISTANCE_MAX = 12; this.THIRD_DISTANCE_DEFAULT = 5; this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока 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; // Порог авто-перехода third→first при зуме колесом (Roblox-style). this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7; // Если true — нельзя выйти из first-person зумом (lockfirst-режим). this._lockFirstPerson = false; // Shift-Lock (Roblox-style): курсор зафиксирован, корпус лицом к камере. this._shiftLock = false; // Задача 02: ПКМ зажата сейчас (orbit в third) + видимость курсора. this._rmbHeld = false; this._mouseIconVisible = true; // Ввод 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 текущего скина // === non-humanoid скины (задача 07) === // Скин без R15-скелета (животное, машина, абстрактная модель). // Для них центрируем pivot, считаем собственный AABB и анимируем // процедурно через _animateNonHumanoidMesh (см. _loadPlayerModel/_tick). this._modelKind = 'r15'; // 'r15' | 'non-humanoid-mesh' this._modelHipHeight = null; // локальная база модели (опущена на ноги) this._nonHumanoidBox = null; // {hw,hh,hd} собственный AABB модели this._lastFrameSpeed = 0; // горизонтальная скорость кадра (для анимаций) this._isGrounded = true; // флаг «на земле» (для анимаций) // === Блокировка ввода/камеры для модалов (задача 04) === this._inputBlocked = false; // глотает игровой ввод (кроме Esc/Tab/Enter) this._cameraFrozen = false; // замораживает вращение/зум камеры this._savedCameraState = null;// сохранённое состояние камеры (focusOnTarget) // === Жизни игрока === 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); // === Задача 02: pointer-lock берём ТОЛЬКО в режимах с постоянным lock // (first/lockfirst/sideview/shift-lock). В third курсор виден свободно — // кликает GUI и 3D-таблички, камера крутится только при зажатой ПКМ. if (this._isPermaLockMode()) { this._requestPointerLockSafe(); } this._applyCursorVisibility(); } /** Задача 02: режим с ПОСТОЯННЫМ pointer-lock (мышь всегда крутит камеру). */ _isPermaLockMode() { return this._cameraMode === 'first' || this._cameraMode === 'lockfirst' || this._cameraMode === 'sideview' || this._shiftLock; } /** Задача 02: показать/скрыть курсор. В third виден (если нет lock), в * first/lock — скрыт. Учитывает game.input.setMouseIconVisible. */ _applyCursorVisibility() { if (!this.canvas) return; const locked = (document.pointerLockElement === this.canvas); const show = (this._mouseIconVisible !== false) && !locked; try { this.canvas.style.cursor = show ? '' : 'none'; } catch (e) { /* ignore */ } } /** * Безопасный запрос 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) { // Открываем UI (меню/курсор) → сбрасываем удержание ПКМ. Иначе если // меню открыли при зажатой ПКМ, _rmbHeld застревает в true и orbit- // камера после закрытия меню «думает», что ПКМ всё ещё активна. this._rmbHeld = false; // Освобождаем мышь 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; // ВАЖНО: объединяем ОБА источника, а не «или-или». // Баг (2026-05-30): раньше при непустом /rublox/avatars возвращался // ТОЛЬКО он, а статичный skins_manifest.json (где встроенные // non-humanoid скины — еда/машины/животные: skin_burger, squirrel-donut // и т.д.) НЕ подгружался. setSkin('burger') не находил entry → fallback // на несуществующий characters/skin_burger/body.glb → 404 → краш GLTF // (Unexpected magic) → старая модель уже выгружена, новая не создаётся → // скин исчезал. Теперь грузим статичный манифест ВСЕГДА, плюс аватары. let combined = []; // 1) Статичный JSON (встроенные скины, включая non-humanoid). try { const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); if (resp.ok) { const json = await resp.json(); if (Array.isArray(json.skins)) combined = combined.concat(json.skins); } } catch (e) { // eslint-disable-next-line no-console console.warn('[PlayerController] skins_manifest load failed:', e); } // 2) БД rublox_avatars (легаси + дизайнерские аватары после approve). try { const resp = await fetch(_storysApiUrl('/rublox/avatars')); if (resp.ok) { const json = await resp.json(); const items = json.items || []; // Нормализуем: file уже полный путь (absolute_file=true), т.к. // _resolveModelSource иначе добавляет '/kubikon-assets/' префикс. const avatars = items.map((a) => ({ id: a.code, name: a.name, file: a.file_path, overrides: a.overrides || {}, absolute_file: true, })); // Аватары имеют приоритет при совпадении id — кладём в начало. const avatarIds = new Set(avatars.map((a) => a.id)); combined = avatars.concat(combined.filter((s) => !avatarIds.has(s.id))); } } catch (e) { // eslint-disable-next-line no-console console.warn('[PlayerController] /rublox/avatars failed:', e); } this._skinManifest = combined; return combined; } /** * Определить путь к GLB и overrides для текущего _modelTypeId. * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста * - иначе → старая Kenney-модель через getModelType() * Возвращает { file, isR15, overrides } или null. */ async _resolveModelSource() { const typeId = this._modelTypeId || 'character-a'; // eslint-disable-next-line no-console console.log(`[PlayerController] _resolveModelSource typeId=${typeId}`); // Подфаза 3.6 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: // body-skin от дизайнера передаётся как 'body:' или // прямой URL ('/api-storys/...' или 'http(s)://...'). // Подтягиваем item из rublox_items.serialize, берём path. if (typeId.startsWith('body:')) { const itemId = typeId.slice(5); const item = await this._fetchBodySkinItem(itemId); if (item && item.path) { return { file: item.path, isR15: true, overrides: {}, // overrides пока не поддерживаем для дизайнерских body }; } // fallback на дефолт return { file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', isR15: true, overrides: {}, }; } // 2026-05-27: 'designer_avatar:' — preview-режим для аватара из БД // (rublox_avatars). Используется в /_preview-avatar/. if (typeId.startsWith('designer_avatar:')) { const avId = typeId.slice('designer_avatar:'.length); const item = await this._fetchDesignerAvatar(avId); if (item && item.file_path) { return { file: item.file_path, isR15: true, overrides: item.overrides || {}, }; } // Fallback на бекона — но с глобальным флагом ошибки, чтобы // preview-route смог показать понятный alert почему. try { window.__previewFallbackReason = this._lastDesignerAvatarError || `Designer avatar #${avId} не загрузился — fallback на бекона`; } catch (e) {} // eslint-disable-next-line no-console console.warn( `[PlayerController] designer_avatar:${avId} НЕ загружен. ` + `Fallback на бекона. Причина: ${this._lastDesignerAvatarError || 'unknown'}` ); return { file: '/kubikon-assets/characters/skin_bacon-hair/body.glb', isR15: true, overrides: {}, }; } if (typeId.startsWith('/') || typeId.startsWith('http')) { // Прямой URL (для preview-режима или тестов). return { file: typeId, isR15: true, 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; } 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'; // absolute_file=true (источник /rublox/avatars) — file уже // полный URL (legacy /kubikon-assets/... или дизайнерский // /api-storys/...). Без флага — это легаси-формат // skins_manifest.json без префикса. const file = entry.absolute_file ? entry.file : '/kubikon-assets/' + entry.file; return { 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, }; } // нет в манифесте — пробуем прямой путь return { file: `/kubikon-assets/characters/${typeId}/body.glb`, isR15: true, kind: 'r15', overrides: {}, }; } const modelType = getModelType(typeId); if (!modelType) return null; return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} }; } /** Подгрузить metadata designer-аватара по id через api-storys. */ async _fetchDesignerAvatar(avatarId) { try { this._designerAvatarCache = this._designerAvatarCache || {}; if (this._designerAvatarCache[avatarId]) { return this._designerAvatarCache[avatarId]; } const jwt = (localStorage.getItem('player_jwt') || localStorage.getItem('Authorization') || ''); const cleanJwt = jwt.startsWith('Bearer ') ? jwt.slice(7) : jwt; const url = _storysApiUrl(`/designer/avatars/${avatarId}`); // eslint-disable-next-line no-console console.log(`[PlayerController] _fetchDesignerAvatar GET ${url}`); // 10-секундный таймаут — fetch без него может висеть бесконечно // (CORS-preflight, медленная сеть, упавший прокси). const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 10000); let resp; try { resp = await fetch(url, { headers: cleanJwt ? { Authorization: 'Bearer ' + cleanJwt } : {}, signal: ctrl.signal, }); } finally { clearTimeout(timer); } if (!resp.ok) { // Получим тело для диагностики let detail = ''; try { const txt = await resp.text(); detail = txt.slice(0, 200); } catch (e) {} // eslint-disable-next-line no-console console.warn( `[PlayerController] _fetchDesignerAvatar id=${avatarId} ` + `HTTP ${resp.status} ${resp.statusText}; jwt=${cleanJwt ? 'есть' : 'НЕТ'}; ` + `body: ${detail}`, ); // Прокидываем ошибку наверх чтобы preview-route показал alert. const err = new Error( `Не удалось получить аватар #${avatarId}: HTTP ${resp.status}. ` + (resp.status === 401 ? 'JWT не валиден или истёк.' : resp.status === 403 ? 'Нет роли «дизайнер» в team.' : resp.status === 404 ? 'Аватар не найден в БД.' : 'См. консоль для деталей.') ); err.status = resp.status; err.detail = detail; throw err; } const data = await resp.json(); const item = data.item || data; this._designerAvatarCache[avatarId] = item; return item; } catch (e) { // eslint-disable-next-line no-console console.warn('[PlayerController] _fetchDesignerAvatar failed:', e); // Сохраняем ошибку чтобы preview-route мог её показать. this._lastDesignerAvatarError = e?.message || String(e); return null; } } /** Подгрузить metadata body-skin (item) по id через api-storys. */ async _fetchBodySkinItem(itemId) { try { // Кэш на инстанс — _modelTypeId не меняется внутри одной сессии. this._bodySkinCache = this._bodySkinCache || {}; if (this._bodySkinCache[itemId]) return this._bodySkinCache[itemId]; const resp = await fetch( _storysApiUrl(`/designer/skins/${itemId}`), { headers: { Authorization: 'Bearer ' + (localStorage.getItem('player_jwt') || localStorage.getItem('Authorization') || ''), }, }, ); if (!resp.ok) { // eslint-disable-next-line no-console console.warn('[PlayerController] _fetchBodySkinItem HTTP', resp.status); return null; } const data = await resp.json(); const item = data.item || data; this._bodySkinCache[itemId] = item; return item; } catch (e) { // eslint-disable-next-line no-console console.warn('[PlayerController] _fetchBodySkinItem failed:', e); return null; } } /** Загрузить GLB-модель персонажа и его анимации. */ async _loadPlayerModel() { // eslint-disable-next-line no-console console.log(`[PlayerController] _loadPlayerModel start, modelTypeId=${this._modelTypeId}`); const source = await this._resolveModelSource(); // eslint-disable-next-line no-console console.log(`[PlayerController] _resolveModelSource → ${source ? source.file : 'NULL'}`); if (!source) return; if (!this._active) return; // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш // ModelManager. Если бы мы использовали тот же AssetContainer // что и зомби (через _loadPrototype), повторный // instantiateModelsToScene давал меши с битыми материалами. // Babylon HTTP-кэш всё равно убирает сетевые запросы. // // ВАЖНО (2026-05-27): file_path для дизайнерских аватаров приходит // как '/api-storys/...' — на проде player.rublox.pro это даст 404 // (там нет такого пути). Конвертируем в абсолютный URL minecraftia. let absFile = source.file; if (absFile && absFile.startsWith('/api-storys/')) { const isDev = (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')); if (!isDev) { absFile = 'https://minecraftia-school.ru' + absFile; } } let rootUrl, filename; if (source.isDataUrl) { // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl='' // и filename=data:... с подсказкой расширения через 5-й аргумент. rootUrl = ''; filename = absFile; } else { const lastSlash = absFile.lastIndexOf('/'); rootUrl = absFile.substring(0, lastSlash + 1); filename = absFile.substring(lastSlash + 1); } // eslint-disable-next-line no-console console.log(`[PlayerController] SceneLoader.Load: ${rootUrl}${filename}`); // Прогресс-индикатор для больших GLB (некоторые дизайнерские // аватары до 60 МБ — на медленной сети идут минутами, без прогресса // выглядит как зависание). Публикуем в глобал чтобы PreviewRoute // мог его показать. try { window.__playerLoadProgress = { loaded: 0, total: 0 }; } catch (e) {} const onProgress = (evt) => { try { if (evt && evt.lengthComputable) { window.__playerLoadProgress = { loaded: evt.loaded, total: evt.total, }; const pct = ((evt.loaded / evt.total) * 100).toFixed(0); // eslint-disable-next-line no-console console.log(`[PlayerController] GLB загрузка: ${pct}% ` + `(${(evt.loaded / 1024 / 1024).toFixed(1)}/` + `${(evt.total / 1024 / 1024).toFixed(1)} МБ)`); } } catch (e) {} }; let container; try { container = await SceneLoader.LoadAssetContainerAsync( rootUrl, filename, this.scene, onProgress, source.isDataUrl ? '.glb' : undefined, ); try { window.__playerLoadProgress = null; } catch (e) {} } catch (e) { try { window.__playerLoadProgress = null; } catch (e2) {} // 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 множитель из манифеста. // Non-humanoid скины (животное/машина/еда) масштабируются иначе: // базовый размер из манифеста (scale), без фикс-0.301. const isNonHumanoid = source.kind === 'non-humanoid-mesh' || source.kind === 'non-humanoid-rigged'; let modelScale; if (isNonHumanoid) { 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; // Фолбэк для GLB с face_dir_invert (старый Blender→glTF // конвейер экспортировал лицом в -Z). После 2026-05-27 // запеченные через bake_avatars_rotate.sh аватары флаг не // требуют, но механизм оставлен для будущих кривых загрузок. if (source.overrides && source.overrides.face_dir_invert) { r.rotation.y = (r.rotation.y || 0) + Math.PI; } } this._modelRoot = root; this._modelKind = source.kind || 'r15'; // hipHeight: на сколько центр модели поднят от «низа ног». 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; } // Тени: персонаж принимает тени от мира (тень дерева // ложится на тело игрока) и сам отбрасывает тень — caster // регистрируется отдельно через _scene3d.addShadowCaster. m.receiveShadows = true; } // Игрок ОТБРАСЫВАЕТ тень — регистрируем mesh-части как shadow casters. 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(); // Подфаза 3.3 — создаём AccessoryManager после успешной загрузки // тела. Если был старый (после смены скина) — его аксессуары // уже задиспозены вместе со старым modelRoot, просто // создаём заново. this._accessoryManager = new AccessoryManager( this.scene, this._r15Skeleton, this._modelRoot, ); } 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; } // ─── Аксессуары (Подфаза 3.3 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md) ── /** * Надеть аксессуар. item — RubloxItem.serialize с полем attachment. * Возвращает Promise (handle.dispose() снимает). * Если в этом слоте уже надето что-то — старое снимается автоматом. */ async equipAccessory(item) { if (!this._accessoryManager) { // eslint-disable-next-line no-console console.warn('[PlayerController] equipAccessory: model not loaded yet'); return null; } return this._accessoryManager.attach(item); } /** Снять аксессуар из слота (hat/tool/hair/face/...). */ unequipSlot(slot) { if (this._accessoryManager) this._accessoryManager.detachSlot(slot); } /** Снять все аксессуары. */ unequipAll() { if (this._accessoryManager) this._accessoryManager.detachAll(); } /** Геттер для прямого доступа (used by calibration UI / DevTools). */ getAccessoryManager() { return this._accessoryManager || null; } /** 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(); } /** * Проиграть кастомный emote из GLB-spec (см. EmoteGlbParser). * Используется в preview-режиме теста дизайнерских emote. */ playCustomEmote(spec) { if (this._isR15 && this._r15Animator) { return this._r15Animator.playCustomEmote(spec); } return false; } _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: вождение машины ===== enterVehicle(veh) { if (!veh) return; this._inVehicle = veh; this._vehicleCamMode = 'follow'; veh.driver = 'player'; if (this._codes) this._codes.clear(); this._skinVisibleScripted = false; this._startEngineSound(); } // Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум + // 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; const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45; 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; const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5; 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.connect(lfoGain); lfoGain.connect(gain.gain); osc.start(); noise.start(); lfo.start(); this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain }; } catch (e) {} } _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; n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12); 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); n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12); } catch (e) {} } _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) {} 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) {} } } } } cycleVehicleCamera() { const modes = ['follow', 'hood', 'cinematic']; const i = modes.indexOf(this._vehicleCamMode || 'follow'); this._vehicleCamMode = modes[(i + 1) % modes.length]; } _tickVehicle(dt) { const veh = this._inVehicle; if (!veh || !this._scene3d?.vehicleManager) 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 _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt); this._updateEngineSound(veh.speed, veh.params?.maxSpeed); if (_vres && _vres.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 { 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); } 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(); } /** Включить/выключить 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)); } _setupInput() { const canvas = this.canvas; // Задача 02 (как в студии): хелпер — режим с ПОСТОЯННЫМ pointer-lock. const needPermLock = () => ( this._cameraMode === 'first' || this._cameraMode === 'lockfirst' || this._cameraMode === 'sideview' || this._shiftLock ); const onCanvasClick = () => { // В UI-режиме клик не перехватывает мышь. if (this._uiCursorMode) return; if (!this._active) return; // Roblox-style: в third-person ЛКМ-клик НЕ лочит курсор (он остаётся // свободным для GUI/3D-onClick). Lock запрашиваем ТОЛЬКО для режимов // где курсор постоянно скрыт, и только если lock был снят. if (!needPermLock()) return; if (document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch (e) { /* ignore */ } } }; canvas.addEventListener('click', onCanvasClick); // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру === // Зажал ПКМ → курсор скрыт, мышь крутит камеру. Отпустил → курсор вернулся. const onCanvasMouseDownGlobal = (e) => { if (!this._active || this._uiCursorMode) return; if (e.button !== 2) return; // только ПКМ if (needPermLock()) return; // в perma-режимах ПКМ ничего не делает this._rmbHeld = true; if (document.pointerLockElement !== canvas) { try { const p = canvas.requestPointerLock?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch (err) { /* ignore */ } } e.preventDefault(); }; const onWindowMouseUpGlobal = (e) => { if (e.button !== 2) return; if (!this._rmbHeld) return; this._rmbHeld = false; if (needPermLock()) return; if (document.pointerLockElement === canvas) { try { document.exitPointerLock(); } catch (err) { /* ignore */ } } }; canvas.addEventListener('mousedown', onCanvasMouseDownGlobal); window.addEventListener('mouseup', onWindowMouseUpGlobal); canvas.addEventListener('contextmenu', (e) => { if (this._active) e.preventDefault(); }); // === UI-режим: mousedown / mouseup → callback (для drag-игр) === const onCanvasMouseDown = (e) => { 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; // _invertCamera (GameMenu Настройки → Инвертировать) — флипает Y. const pitchSign = this._invertCamera ? -1 : 1; this._pitch += e.movementY * this.MOUSE_SENSITIVITY * pitchSign; 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); // Задача 02: колесо = зум third-камеры с авто-переходом third↔first. const onWheel = (e) => { if (!this._active) return; if (this._cameraFrozen) { e.preventDefault(); return; } // модал if (this._cameraMode === 'sideview') { e.preventDefault(); return; } // GD // В first зум наружу возвращает в third (если не lockfirst). if (this._cameraMode === 'first') { if (e.deltaY > 0 && !this._lockFirstPerson) { this._cameraMode = 'third'; this._thirdDistance = (this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7) + 0.5; if (!this._isPermaLockMode() && document.pointerLockElement === canvas) { try { document.exitPointerLock(); } catch (err) { /* ignore */ } } this._applyCursorVisibility(); this._applyCameraMode?.(); } e.preventDefault(); return; } if (this._cameraMode !== 'third') { e.preventDefault(); return; } // Экспоненциальный шаг (плавнее вблизи). this._thirdDistance += Math.sign(e.deltaY) * Math.max(0.3, this._thirdDistance * 0.15); if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN; if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX; // Зум внутрь до порога → авто-переход в first (Roblox-style). const THRESH = this.FIRST_PERSON_ZOOM_THRESHOLD || 0.7; if (this._thirdDistance <= THRESH) { this._cameraMode = 'first'; this._requestPointerLockSafe(); this._applyCursorVisibility(); this._applyCameraMode?.(); } e.preventDefault(); }; canvas.addEventListener('wheel', onWheel, { passive: false }); let wasLocked = false; const onPointerLockChange = () => { const locked = document.pointerLockElement === canvas; this._applyCursorVisibility?.(); // задача 02: вернуть/скрыть курсор if (locked) { wasLocked = true; this._rmbHeld = true; // если попали в lock — ПКМ удерживается } else if (wasLocked && this._active) { // pointer-lock снят. Причин три: // 1) пользователь в UI-режиме (game.input.setCursorMode('ui')) // 2) ПКМ отпущена в third-person (orbit-камера завершена) // 3) Esc → выход из Play (если был в first/lockfirst/sideview/shift) wasLocked = false; this._rmbHeld = false; if (this._uiCursorMode) { this._applyCursorVisibility?.(); return; } if (needPermLock()) { // Был режим с постоянным lock'ом и его сняли (Esc) → выход. if (this._onExitRequest) this._onExitRequest(); } else { // Third-person: просто отпустили ПКМ. Остаёмся в Play, // курсор вернулся — это НЕ повод открывать меню. this._applyCursorVisibility?.(); } } }; document.addEventListener('pointerlockchange', onPointerLockChange); 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); // Задача 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) {} } } } 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; // === Задача 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 { 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); } }