player/src/engine/PlayerController.js
min eb6430182b
All checks were successful
CI / Lint (pull_request) Successful in 57s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
feat(14): Vehicle System V1+V2 — порт в плеер
Фича-парность со студией (задача 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>
2026-06-03 02:25:15 +03:00

3250 lines
174 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}