All checks were successful
Фича-парность со студией (задача 14):
- VehicleManager + VehicleHud (спидометр-стрелка) идентичны студийным.
- game.scene.spawn('vehicle:car'), onVehicleEnter/Exit, hold-F/E, камера follow/V.
- Звук мотора (рокот+LFO), оседание машины на землю (_settle+повторы),
скрытие водителя, респавн при падении, shadow-caster фильтр (фикс FPS).
- incrementPlay(id, userId) — передаём user_id для cooldown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3250 lines
174 KiB
JavaScript
3250 lines
174 KiB
JavaScript
/**
|
||
* 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/<id>/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/<id>/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:<item_id>' или
|
||
// прямой 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:<id>' — preview-режим для аватара из БД
|
||
// (rublox_avatars). Используется в /_preview-avatar/<id>.
|
||
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:<slug>'. 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> (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);
|
||
}
|
||
}
|