studio/src/editor/engine/PlayerController.js
min 8f0266f8c2
All checks were successful
CI / Lint (pull_request) Successful in 1m7s
CI / Build (pull_request) Successful in 2m2s
CI / Secret scan (pull_request) Successful in 24s
CI / PR size check (pull_request) Successful in 8s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
fix(skin): cache-bust query ?v=20260614 for character-assets URLs
After backend CORS rollout users had stale CORS-failure cached for
Mixamo GLB. Adding a query suffix forces browsers to re-fetch the URL
instead of replaying the cached failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 16:43:34 +03:00

3189 lines
171 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';
import { MixamoAnimator, loadMixamoAnimations } from './MixamoAnimator';
// Список всех Mixamo-скинов. Должен совпадать со списком в плеере и
// каталоге сайта (rublox-site/src/data/skinsCatalog.js).
const MIXAMO_SKINS = new Set([
'skin_aj', 'skin_akai', 'skin_arissa', 'skin_big-vegas',
'skin_castle-guard-1', 'skin_castle-guard-2',
'skin_ch01', 'skin_ch02', 'skin_ch03', 'skin_ch04', 'skin_ch07', 'skin_ch08',
'skin_ch09', 'skin_ch10', 'skin_ch11', 'skin_ch13', 'skin_ch14', 'skin_ch15',
'skin_ch16', 'skin_ch17', 'skin_ch18', 'skin_ch19', 'skin_ch20', 'skin_ch21',
'skin_ch22', 'skin_ch23', 'skin_ch24', 'skin_ch29', 'skin_ch31', 'skin_ch32',
'skin_ch33', 'skin_ch34', 'skin_ch35', 'skin_ch39', 'skin_ch40', 'skin_ch42',
'skin_ch43', 'skin_ch44', 'skin_ch45', 'skin_ch46', 'skin_ch47', 'skin_ch48',
'skin_claire', 'skin_demon', 'skin_ely', 'skin_erika-archer',
'skin_eve', 'skin_exo-gray', 'skin_exo-red', 'skin_ganfaul', 'skin_heraklios',
'skin_kachujin', 'skin_kaya', 'skin_knight', 'skin_lola', 'skin_maria',
'skin_maw', 'skin_medea', 'skin_mutant', 'skin_nightshade',
'skin_paladin', 'skin_passive-marker-man', 'skin_peasant-girl', 'skin_peasant-man',
'skin_prisoner', 'skin_pumpkinhulk', 'skin_skeleton-zombie', 'skin_sporty-granny',
'skin_survivor', 'skin_swat', 'skin_ty', 'skin_uriel', 'skin_vampire',
'skin_war-zombie', 'skin_warrok', 'skin_white-clown', 'skin_x-bot', 'skin_y-bot',
]);
// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
const CAMERA_MODES = ['third', 'first', 'front'];
// Для режима 'sideview' (Кубикон Dash):
// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб
// и ~12м препятствий впереди влезали в кадр на 16:9.
const SIDEVIEW_DIST = 14;
const SIDEVIEW_HEIGHT = 2.5;
export class PlayerController {
constructor(scene, canvas, physics, scene3d = null) {
this.scene = scene;
this.canvas = canvas;
this.physics = physics;
this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint)
this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали
// AABB
this.HALF_W = 0.3;
this.HALF_H = 0.9;
this.HALF_D = 0.3;
this.EYE_HEIGHT = 0.7; // глаза от центра AABB
this.WALK_SPEED = 4.5;
this.SPRINT_MULT = 1.7;
this.JUMP_VELOCITY = 8;
this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне)
this._speedMul = 1; // множитель скорости передвижения
this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная)
this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт)
this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе
this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
// Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
// Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
this._autoRunSpeed = 0;
// Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе).
// В sideview-камере при прыжке куб эффектно крутится — визитка GD.
this._dashSpinAngle = 0;
// Camera shake: amplitude + remaining time. Применяется в _tick после
// _computeCameraPos. Используется через game.camera.shake(amp, dur).
this._cameraShakeAmp = 0;
this._cameraShakeLeft = 0;
// Управление камерой из скрипта (Фаза 5.7). null = обычная камера
// игрока. Иначе объект режима:
// { mode:'focus', getTarget } — следить за объектом;
// { mode:'cutscene', points, durations, ... } — пролёт по точкам.
this._cameraOverride = null;
// Coyote-time: окно после схода с платформы когда ещё можно прыгнуть.
// Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround.
this._coyoteLeft = 0;
this._doubleJumpEnabled = false;
this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте»
// Кубикон Dash: направление гравитации. +1 = нормально (вниз),
// -1 = инвертировано (вверх, как после blue orb / gravity portal в GD).
// Применяется только в sideview-режиме. Влияет на:
// - vy += GRAVITY * gravityDir * dt
// - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз)
// - "onGround" определение: hitY + vy*gravityDir < 0
// Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте).
this._gravityDir = 1;
// Скользкость (лёд): 0 = нормальное мгновенное движение/остановка,
// 1 = полностью скользко (инерция держится бесконечно). Реалистичный
// лёд = ~0.85. Настраивается через game.player.setIceFriction(value).
this._iceFriction = 0;
this._iceVelX = 0;
this._iceVelZ = 0;
// Присед — уменьшает высоту AABB. Включается через
// game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45.
this._crouching = false;
this.HALF_H_NORMAL = 0.9;
this.HALF_H_CROUCH = 0.45;
this.GRAVITY = -22;
this.MOUSE_SENSITIVITY = 0.0025;
// 3rd person camera (Roblox-style: 0.5 .. 32)
this.THIRD_DISTANCE_MIN = 0.5;
this.THIRD_DISTANCE_MAX = 32;
this.THIRD_DISTANCE_DEFAULT = 5;
this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока
// Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5)
this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
// Lockfirst-режим: нельзя выйти из first-person зумом наружу
this._lockFirstPerson = false;
// Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере
// (включается клавишей L по дефолту, или game.player.setShiftLock(true))
this._shiftLock = false;
// Видимость курсора по умолчанию (game.input.setMouseIconVisible)
this._mouseIconVisible = true;
// Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован)
// / 'lockcurrent' (зафиксирован на текущей позиции)
this._mouseBehavior = 'default';
// Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third)
this._rmbHeld = false;
this.camera = null;
this._active = false;
this._onExitRequest = null;
// Состояние игрока
this._pos = new Vector3(0, 5, 0);
this._vy = 0;
this._yaw = 0;
this._pitch = 0;
// Камера. Дефолт — первое лицо (как в большинстве игр).
this._cameraMode = 'third';
this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
// Ввод
this._codes = new Set();
this._shift = false;
// Auto-step visual smoothing. Когда PhysicsAABB телепортирует
// игрока вверх на уступ (steppedUpBy), физически он уже наверху,
// но мы интерполируем рендер: показываем модель и камеру со сдвигом
// ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0.
// Получается визуально как плавный «полупрыжок» без рывка.
this._stepUpVisualOffset = 0;
// Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс.
this._stepUpDecay = 4.5;
// Модель игрока (грузится в start)
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
this._modelTypeId = 'skin_bacon-hair';
this._modelRoot = null;
this._modelMeshes = [];
// Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
// _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР
// в _tick (после анимаций), чтобы скрин оставался скрытым даже после
// асинхронной загрузки модели или после _applyCameraMode.
this._skinVisibleScripted = true;
this._animations = {};
this._currentAnim = null;
// Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока).
// Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72.
this._modelScale = 0.72;
// === R15-скин (bacon-hair и др.) ===
// R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций).
// Если _modelTypeId начинается с 'skin_' — грузим R15-скин из
// characters/<id>/body.glb, детектируем скелет, анимируем
// процедурно через R15Animator (см. _loadPlayerModel / _tick).
this._isR15 = false; // флаг: загружен валидный R15-скелет
this._r15Skeleton = null; // R15Skeleton — резолвер костей
this._r15Animator = null; // R15Animator — процедурные анимации
this._mixamoAnimator = null; // MixamoAnimator — Mixamo-скины
this._skinManifest = null; // кеш skins_manifest.json
this._skinOverrides = {}; // overrides текущего скина
// === Жизни игрока ===
this.maxHp = 100;
this.hp = 100;
this._lastDamageTime = 0;
this._invulnerabilityTime = 0.5; // 500мс i-frames после удара
this._onHpChange = null;
this._onDeath = null;
// Звук шагов — простой генератор через Web Audio API.
// Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE.
this._audioCtx = null;
this._distanceSinceLastStep = 0;
this.STEP_DISTANCE_WALK = 1.6;
this.STEP_DISTANCE_SPRINT = 1.1;
// Угол поворота модели — следует за направлением движения, а не yaw камеры.
// Когда игрок стоит — сохраняем последний угол.
this._modelYaw = 0;
this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с)
this._listeners = [];
this._beforeRender = null;
// === Stick к движущейся платформе ===
// Если игрок стоит на примитиве/модели — следующий кадр сдвигает его
// на дельту движения этого объекта (его позиция могла измениться).
this._lastGroundData = null;
this._lastGroundPos = null;
// === Тач-режим (мобилки/планшеты) ===
// Если true — pointer-lock не запрашивается, mouse-listener не активен,
// ввод управляется снаружи через setVirtualKey/addCameraDelta.
this._touchMode = false;
// Фактическая скорость поворота камеры от тача (рад/пиксель).
this.TOUCH_SENSITIVITY = 0.005;
}
/**
* Включить тач-режим. Вызывать ДО start(), на тач-устройствах.
* В этом режиме:
* - pointer-lock НЕ запрашивается
* - mousemove игнорируется
* - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры),
* но дополнительно работают setVirtualKey() / addCameraDelta().
*/
setTouchMode(enabled) {
this._touchMode = !!enabled;
}
/**
* Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code:
* 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space'
* Для шифта — отдельный параметр.
*/
setVirtualKey(code, pressed) {
if (pressed) this._codes.add(code);
else this._codes.delete(code);
}
/** Программное нажатие/отпускание Shift (бег). */
setVirtualShift(pressed) {
this._shift = !!pressed;
}
/**
* Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены).
* dx, dy — пиксели свайпа.
*/
addCameraDelta(dx, dy) {
this._yaw += dx * this.TOUCH_SENSITIVITY;
this._pitch += dy * this.TOUCH_SENSITIVITY;
const lim = Math.PI / 2 - 0.05;
if (this._pitch > lim) this._pitch = lim;
if (this._pitch < -lim) this._pitch = -lim;
}
/** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */
triggerJump() {
this._codes.add('Space');
// Через 100мс отпускаем — этого хватает контроллеру чтобы заметить
// нажатие и инициировать прыжок (он проверяет onGround + Space).
setTimeout(() => this._codes.delete('Space'), 100);
}
/**
* Аналоговый ввод движения для тач-джойстика.
* x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры),
* x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint).
*
* Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться
* к дискретным клавишам, передай null.
*/
setAnalogMove(x, y) {
if (x === null || y === null) {
this._analogMove = null;
return;
}
if (!this._analogMove) this._analogMove = { x: 0, y: 0 };
this._analogMove.x = x;
this._analogMove.y = y;
}
setOnExitRequest(cb) {
this._onExitRequest = cb;
}
/** Установить тип модели персонажа — должен быть вызван ДО start(). */
setModelType(typeId) {
this._modelTypeId = typeId || 'character-a';
}
/**
* Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
* Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
* грузит новую модель (R15 или non-humanoid). Возвращает Promise.
*
* Используется из game.player.setSkin(slug).
*/
async reloadSkin(typeId) {
if (!this._active) return false;
const newType = typeId || 'character-a';
if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
// 1) Выгрузить текущую модель и связанные аниматоры.
try {
if (this._modelRoot) { this._modelRoot.dispose(false, true); }
} catch (e) { /* ignore */ }
this._modelRoot = null;
this._modelMeshes = [];
this._rightArmMeshes = [];
this._r15Skeleton = null;
this._r15Animator = null;
this._isR15 = false;
try { if (this._mixamoAnimator) this._mixamoAnimator.detach(); } catch (e) {}
this._mixamoAnimator = null;
this._modelKind = 'r15';
this._modelHipHeight = null;
this._nonHumanoidBox = null;
// 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
this.HALF_W = 0.3;
this.HALF_H = 0.9;
this.HALF_D = 0.3;
this.HALF_H_NORMAL = 0.9;
this.EYE_HEIGHT = 0.7;
// 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
this._pos.y += 0.5;
// 4) Загрузить новую модель.
this._modelTypeId = newType;
await this._loadPlayerModel();
return !!this._modelRoot;
}
/**
* Запустить режим игры.
* spawnPos — точка спавна. Если не указано — (0, 5, 0).
*/
async start(spawnPos = null) {
if (this._active) return;
this._active = true;
if (spawnPos) {
this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z);
} else {
this._pos = new Vector3(0, 5 + this.HALF_H, 0);
}
this._vy = 0;
this._yaw = 0;
this._pitch = 0;
this._modelYaw = 0;
this._codes.clear();
this._shift = false;
// FPS-камера
const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene);
cam.minZ = 0.1;
cam.maxZ = 1000;
cam.fov = 1.05;
cam.inputs.clear();
this.scene.activeCamera = cam;
this.camera = cam;
this._setupInput();
// Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит
// без меша (или появляется частично), а движение/колайдер уже
// активны. start() теперь async-функция — все её вызовы (`await`).
await this._loadPlayerModel();
// Render-loop hook
this._beforeRender = () => this._tick();
this.scene.registerBeforeRender(this._beforeRender);
// Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу:
// - first / lockfirst — постоянный lock
// - sideview (GD) — раньше тоже лочил, оставляем для авто-управления
// Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit).
// ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'.
const needLockAtStart = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (needLockAtStart) {
this._requestPointerLockSafe();
}
// Применяем видимость курсора (по умолчанию виден в third).
this._applyCursorVisibility();
}
/**
* Установить курсор видимым/скрытым через CSS на canvas.
* Pointer-lock сам прячет курсор когда активен, но в third без lock
* мы можем скрыть курсор через `cursor:none` если разработчик
* выключил его через setMouseIconVisible(false).
*/
_applyCursorVisibility() {
if (!this.canvas) return;
const locked = (document.pointerLockElement === this.canvas);
// Если lock активен — курсор и так скрыт. Иначе зависит от настроек.
if (locked) return;
const show = this._mouseIconVisible && !this._shiftLock;
this.canvas.style.cursor = show ? '' : 'none';
}
/**
* Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
* Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём
* pointerlockchange и пробуем снова один раз.
*/
/**
* Включить/выключить «UI-режим курсора».
* В этом режиме мышь свободна (можно кликать по GUI), камера не вращается.
* Чтобы вернуться к управлению камерой — снова setUiCursorMode(false).
*/
/** Колбэк изменения HP — ({hp, maxHp}). */
setOnHpChange(cb) { this._onHpChange = cb; }
setOnDeath(cb) { this._onDeath = cb; }
/** Нанести урон игроку (с учётом i-frames). */
takeDamage(amount, source) {
if (this.hp <= 0) return;
const now = performance.now() / 1000;
if (now - this._lastDamageTime < this._invulnerabilityTime) return;
this._lastDamageTime = now;
this.hp = Math.max(0, this.hp - Math.max(0, amount));
// Flash-эффект для UI (через onHpChange флаг damaged=true)
if (this._onHpChange) {
try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {}
}
// Звук «ой»
this._playHurtSound();
if (this.hp === 0) {
// Эффект распада
this._spawnDeathDebris();
// Прячем модель игрока
if (this._modelRoot) this._modelRoot.setEnabled(false);
if (this._onDeath) {
try { this._onDeath(); } catch (e) {}
}
}
}
/** Распад на куски при смерти. */
_spawnDeathDebris() {
if (!this._pos) return;
const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z;
const colors = [
new Color3(0.95, 0.78, 0.6), // кожа
new Color3(0.7, 0.5, 0.4),
new Color3(0.4, 0.4, 0.7), // одежда
new Color3(0.3, 0.25, 0.2),
];
for (let i = 0; i < 10; i++) {
const size = 0.18 + Math.random() * 0.14;
const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene);
const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene);
mat.diffuseColor = colors[i % colors.length];
mat.specularColor = new Color3(0, 0, 0);
cube.material = mat;
cube.position.set(
cx + (Math.random() - 0.5) * 0.5,
cy + Math.random() * 0.6,
cz + (Math.random() - 0.5) * 0.5
);
cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
cube.isPickable = false;
cube.alwaysSelectAsActiveMesh = true;
const debris = {
mesh: cube, mat,
vx: (Math.random() - 0.5) * 5,
vy: 4 + Math.random() * 3,
vz: (Math.random() - 0.5) * 5,
rx: (Math.random() - 0.5) * 10,
ry: (Math.random() - 0.5) * 10,
rz: (Math.random() - 0.5) * 10,
age: 0,
life: 2.0,
};
if (!this._debris) this._debris = [];
this._debris.push(debris);
}
}
/** Тик debris — вызывается в _tick. */
_tickDebris(dt) {
if (!this._debris || this._debris.length === 0) return;
const G = -10;
const next = [];
for (const d of this._debris) {
d.age += dt;
if (d.age >= d.life) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
continue;
}
d.vy += G * dt;
d.mesh.position.x += d.vx * dt;
d.mesh.position.y += d.vy * dt;
d.mesh.position.z += d.vz * dt;
if (d.mesh.position.y < 0.1) {
d.mesh.position.y = 0.1;
d.vy *= -0.4;
d.vx *= 0.6;
d.vz *= 0.6;
}
d.mesh.rotation.x += d.rx * dt;
d.mesh.rotation.y += d.ry * dt;
d.mesh.rotation.z += d.rz * dt;
const fadeStart = d.life - 0.5;
if (d.age > fadeStart) {
const k = 1 - (d.age - fadeStart) / 0.5;
d.mesh.visibility = Math.max(0, k);
}
next.push(d);
}
this._debris = next;
}
/** Короткий звук «ой» когда получили урон. */
_playHurtSound() {
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const t = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(220, t);
osc.frequency.exponentialRampToValueAtTime(80, t + 0.15);
const g = ctx.createGain();
g.gain.setValueAtTime(0.15, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
osc.connect(g).connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.22);
} catch (e) { /* ignore */ }
}
/** Полное восстановление HP (например при респавне). */
healFull() {
this.hp = this.maxHp;
this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна
// Возвращаем модель
if (this._modelRoot) this._modelRoot.setEnabled(true);
// Сбрасываем оставшиеся debris
if (this._debris) {
for (const d of this._debris) {
try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
}
this._debris = [];
}
if (this._onHpChange) {
try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {}
}
}
setUiCursorMode(enabled) {
this._uiCursorMode = !!enabled;
if (enabled) {
// Освобождаем мышь
if (document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) { /* ignore */ }
}
} else {
// Возвращаем lock — но только если мы реально активны
if (this._active) {
this._requestPointerLockSafe();
}
}
}
isUiCursorMode() { return !!this._uiCursorMode; }
/**
* Callback, который вызывается при движении мыши в UI-режиме.
* fn(x, y) — нормализованные координаты [0..1] относительно канваса.
* Используется для drag-механик (Дальгона и т.д.).
*/
setUiMouseMoveCallback(fn) {
this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null;
}
/** mousedown в UI-режиме. fn(x, y). */
setUiMouseDownCallback(fn) {
this._uiMouseDownCb = (typeof fn === 'function') ? fn : null;
}
/** mouseup в UI-режиме. fn(x, y). */
setUiMouseUpCallback(fn) {
this._uiMouseUpCb = (typeof fn === 'function') ? fn : null;
}
_requestPointerLockSafe(retried = false) {
if (!this._active || !this.canvas?.requestPointerLock) return;
// На тач-устройствах pointer-lock не нужен — управление через touch-overlay
if (this._touchMode) return;
// UI-режим (скрипт включил курсор через game.input.setCursorMode('ui'))
// — не захватываем мышь.
if (this._uiCursorMode) return;
// Если уже есть lock на этот canvas — нечего делать
if (document.pointerLockElement === this.canvas) return;
// Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем
if (document.pointerLockElement && document.pointerLockElement !== this.canvas) {
if (retried) return; // только одна попытка повтора
const onChange = () => {
document.removeEventListener('pointerlockchange', onChange);
if (this._active) this._requestPointerLockSafe(true);
};
document.addEventListener('pointerlockchange', onChange, { once: true });
return;
}
requestAnimationFrame(() => {
if (!this._active) return;
try {
const p = this.canvas.requestPointerLock();
// Promise-форма: ловим reject (SecurityError) и пробуем повтор
if (p && typeof p.catch === 'function') {
p.catch((err) => {
if (!this._active) return;
// SecurityError — попробуем ещё раз через кадр (один раз)
if (!retried && err && err.name === 'SecurityError') {
setTimeout(() => this._requestPointerLockSafe(true), 50);
}
});
}
} catch (e) { /* legacy form, ignore */ }
});
}
/**
* Загрузить манифест R15-скинов (характеристики + overrides).
* Кешируется в this._skinManifest. Возвращает массив skins или [].
*/
async _loadSkinManifest() {
if (this._skinManifest) return this._skinManifest;
try {
const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
const json = await resp.json();
this._skinManifest = json.skins || [];
} catch (e) {
console.warn('[PlayerController] skins_manifest load failed:', e);
this._skinManifest = [];
}
this._skinManifestBaseUrl = '/kubikon-assets';
return this._skinManifest;
}
/**
* Определить путь к GLB и overrides для текущего _modelTypeId.
* - 'skin_*' → R15-скин из characters/<id>/body.glb + overrides из манифеста
* - иначе → старая Kenney-модель через getModelType()
* Возвращает { file, isR15, overrides } или null.
*/
async _resolveModelSource() {
const typeId = this._modelTypeId || 'character-a';
if (typeId.startsWith('skin_')) {
// 2026-06-14: Mixamo-скины (80 шт) — отдельные GLB на rublox-site
// (/character-assets/skins/), без R15-скелета, с Mixamo-rig.
if (MIXAMO_SKINS.has(typeId)) {
const base = (typeof window !== 'undefined'
&& window.location.hostname === 'localhost')
? 'http://localhost:3000'
: 'https://rublox.pro';
return {
file: `${base}/character-assets/skins/${typeId}.glb?v=20260614`,
isR15: false,
kind: 'non-humanoid-rigged',
overrides: {},
isMixamo: true,
};
}
const manifest = await this._loadSkinManifest();
const entry = manifest.find((s) => s.id === typeId);
const baseUrl = this._skinManifestBaseUrl || '/kubikon-assets';
if (entry) {
const kind = entry.kind || 'r15';
return {
file: baseUrl + '/' + entry.file,
isR15: kind === 'r15',
kind,
overrides: entry.overrides || {},
scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
};
}
return {
file: `${baseUrl}/characters/${typeId}/body.glb`,
isR15: true,
kind: 'r15',
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;
}
const modelType = getModelType(typeId);
if (!modelType) return null;
return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
}
/** Загрузить GLB-модель персонажа и его анимации. */
async _loadPlayerModel() {
const source = await this._resolveModelSource();
if (!source) return;
if (!this._active) return;
// ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
// ModelManager. Если бы мы использовали тот же AssetContainer
// что и зомби (через _loadPrototype), повторный
// instantiateModelsToScene давал меши с битыми материалами.
// Babylon HTTP-кэш всё равно убирает сетевые запросы.
let rootUrl, filename;
if (source.isDataUrl) {
// Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
// и filename=data:... с подсказкой расширения через ?name=.
rootUrl = '';
filename = source.file;
} else {
const lastSlash = source.file.lastIndexOf('/');
rootUrl = source.file.substring(0, lastSlash + 1);
filename = source.file.substring(lastSlash + 1);
}
let container;
try {
container = await SceneLoader.LoadAssetContainerAsync(
rootUrl, filename, this.scene,
null, source.isDataUrl ? '.glb' : undefined
);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[PlayerController] failed to load model:', e);
return;
}
try {
if (!this._active) {
try { container.dispose(); } catch (e) {}
return;
}
// Создаём корневой узел и инстанцируем модель туда
const root = new TransformNode('playerModel', this.scene);
// Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока).
// - R15-скины ('skin_*'): фиксированный 0.301 — модели
// нормализованы к 5.98 ед пайплайном auto_rig_bacon
// (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах
// с торчащими волосами/плащами (как у bacon-hair).
// - Kenney-модели: старый 0.72.
// - overrides.scale_mult — per-skin множитель из манифеста.
const isNonHumanoid = source.kind === 'non-humanoid-mesh'
|| source.kind === 'non-humanoid-rigged';
let modelScale;
if (isNonHumanoid) {
// Non-humanoid: базовый размер берём из манифеста (scale), а если
// нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок).
modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
} else {
modelScale = source.isR15 ? 0.301 : this._modelScale;
const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
modelScale *= scaleMult;
}
root.scaling = new Vector3(modelScale, modelScale, modelScale);
if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
const inst = container.instantiateModelsToScene(
(name) => `player_${name}`,
/*cloneAnimations*/ true,
{ doNotInstantiate: false }
);
for (const r of inst.rootNodes) {
r.parent = root;
}
this._modelRoot = root;
this._modelKind = source.kind || 'r15';
// hipHeight: на сколько центр модели поднят от «низа ног».
// Используется и для позиционирования модели, и для камеры/AABB.
this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
// Non-humanoid: нормализуем размер и опускаем модель на «ноги».
if (isNonHumanoid) {
this._setupNonHumanoidModel(root, modelScale, source);
}
// === R15-скин: детекция скелета ===
// R15-скины приходят с встроенным скелетом Mixamo. Babylon
// распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер
// и, если скелет валидный, помечаем _isR15 + создаём аниматор.
this._isR15 = false;
this._r15Skeleton = null;
this._r15Animator = null;
this._skinOverrides = source.overrides || {};
// eslint-disable-next-line no-console
console.log('[PlayerController] _loadPlayerModel: file=' + source.file
+ ' isR15=' + source.isR15
+ ' inst.skeletons=' + ((inst.skeletons || []).length)
+ ' rootNodes=' + (inst.rootNodes || []).length);
if (source.isR15) {
// Скелет ищем в нескольких местах: inst.skeletons (норма),
// container.skeletons (иногда не клонируется), на мешах
// модели (skeleton-property). Берём первый найденный.
let sk = (inst.skeletons && inst.skeletons[0]) || null;
if (!sk && container.skeletons && container.skeletons.length > 0) {
sk = container.skeletons[0];
}
if (!sk) {
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
if (meshWithSkel) sk = meshWithSkel.skeleton;
}
if (sk) {
// eslint-disable-next-line no-console
console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length
+ ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(','));
const r15 = new R15Skeleton(sk);
if (r15.isValidR15()) {
this._r15Skeleton = r15;
this._isR15 = true;
this._r15Animator = new R15Animator(r15, this._skinOverrides);
// eslint-disable-next-line no-console
console.log('[PlayerController] R15-скин загружен:',
this._modelTypeId, '— костей:', r15.resolvedNames().length,
'overrides:', JSON.stringify(this._skinOverrides));
} else {
// eslint-disable-next-line no-console
console.warn('[PlayerController] R15-скин', this._modelTypeId,
'— скелет не прошёл валидацию. Зарезолвлено:',
r15.resolvedNames().join(','),
'| все кости скелета:',
(sk.bones || []).map(b => b.name).join(','));
}
} else {
// eslint-disable-next-line no-console
console.warn('[PlayerController] R15-скин', this._modelTypeId,
'— нет скелета в glb');
}
}
// Собираем все mesh-чилдрены (для toggle visibility в 1st person)
this._modelMeshes = root.getChildMeshes(false);
// Игрок не должен ловить свой raycast → отключаем pickable
for (const m of this._modelMeshes) {
m.isPickable = false;
if (m.alwaysSelectAsActiveMesh !== undefined) {
m.alwaysSelectAsActiveMesh = true;
}
// Тени: персонаж принимает тени от мира и сам отбрасывает.
m.receiveShadows = true;
}
try {
if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') {
for (const m of this._modelMeshes) {
this._scene3d.addShadowCaster(m);
}
}
} catch (e) { /* ignore */ }
// У Kenney character имена `arm-left`/`arm-right` соответствуют
// СОБСТВЕННОЙ стороне персонажа (его правая = arm-right).
// Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его
// правая рука у нас слева на экране.
//
// Берём именно arm-right (его правую) — это та рука куда логично
// вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4.
this._rightArmMeshes = [];
for (const m of this._modelMeshes) {
const n = (m.name || '').toLowerCase();
// Берём именно «right» — настоящая правая рука персонажа
if (n === 'player_arm-right' || n.endsWith('arm-right')
|| n.includes('right-arm') || n.includes('rightarm')
|| n.includes('right-hand') || n.includes('hand-right')) {
this._rightArmMeshes.push(m);
break;
}
}
// Fallback: если по имени не нашли — берём левую по позиции (x<0)
if (this._rightArmMeshes.length === 0) {
let bestMesh = null;
let bestX = Infinity;
for (const m of this._modelMeshes) {
if (!m.position) continue;
const n = (m.name || '').toLowerCase();
if (n.includes('leg') || n.includes('foot') || n.includes('head')
|| n.includes('hat') || n.includes('torso') || n.includes('root')) continue;
const px = m.position.x;
const py = m.position.y;
if (py < 0.8 || py > 2.2) continue;
if (px >= -0.05) continue; // ищем X<0
if (px < bestX) { bestX = px; bestMesh = m; }
}
if (bestMesh) this._rightArmMeshes = [bestMesh];
}
const arm = this._rightArmMeshes[0];
if (arm) {
this._rightArmX = arm.position.x;
this._rightArmY = arm.position.y;
this._rightArmZ = arm.position.z;
}
// Анимации.
// R15-скины — процедурно через R15Animator.
// Mixamo-скины (non-humanoid-rigged) — через MixamoAnimator
// (5 базовых + lazy эмоции грузятся с /character-assets/animations/).
// Kenney-модели — встроенные AnimationGroups (idle/walk/sprint/jump).
this._animations = {};
this._mixamoAnimator = null;
if (source.isMixamo || source.kind === 'non-humanoid-rigged') {
let mixSk = (inst.skeletons && inst.skeletons[0]) || null;
if (!mixSk && container.skeletons && container.skeletons.length > 0) {
mixSk = container.skeletons[0];
}
if (!mixSk) {
const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
if (meshWithSkel) mixSk = meshWithSkel.skeleton;
}
if (mixSk) {
try {
const animator = new MixamoAnimator();
loadMixamoAnimations(this.scene)
.then(() => {
animator.attach(this.scene, mixSk, root);
animator.setState('idle');
this._mixamoAnimator = animator;
try { window.__mixamo = animator; } catch (e) {}
// eslint-disable-next-line no-console
console.log('[PlayerController] MixamoAnimator готов, скелет=' + mixSk.bones.length + ' bones');
})
.catch((e) => {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator не загрузился:', e);
});
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] MixamoAnimator init fail:', e);
}
}
} else if (!this._isR15) {
const groups = inst.animationGroups || [];
for (const g of groups) {
const name = (g.name || '').toLowerCase();
if (name.includes('idle')) this._animations.idle = g;
else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g;
else if (name.includes('walk')) this._animations.walk = g;
else if (name.includes('jump')) this._animations.jump = g;
g.stop();
}
this._playAnim('idle');
}
// Применяем текущий camera-mode (показать/скрыть модель)
this._applyCameraMode();
} catch (e) {
// eslint-disable-next-line no-console
console.error('[PlayerController] failed to load model:', e);
}
}
/**
* Настройка non-humanoid модели (животное/машина/еда): нормализация
* размера и опускание на «низ ног». В отличие от R15 (нормализованы
* пайплайном), эти модели произвольного размера, поэтому считаем bbox.
*
* Локальные координаты root: модель должна стоять так, чтобы её низ был
* на y=0 (там «ноги»). PlayerController позиционирует root в точке
* `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
*/
_setupNonHumanoidModel(root, scaleApplied, source) {
try {
// Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
// применения scaling root'а. Babylon refreshBoundingInfo нужен после
// инстансинга.
const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
if (!meshes.length) return;
root.computeWorldMatrix(true);
let minY = Infinity, maxY = -Infinity, maxDim = 0;
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const m of meshes) {
m.computeWorldMatrix(true);
// refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
// скелета/морфов; без него minimumWorld у инстансов часто нулевой
// или из исходной позы → центр считался неверно (баг пришельца/робота).
try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
const bi = m.getBoundingInfo();
const bb = bi.boundingBox;
const lo = bb.minimumWorld, hi = bb.maximumWorld;
if (!lo || !hi) continue;
minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
const h = maxY - minY;
const w = maxX - minX;
const d = maxZ - minZ;
maxDim = Math.max(h, w, d);
// === Центрирование модели через pivot-node ===
// Многие Kenney-модели имеют origin НЕ в геометрическом центре
// (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
// origin (баг пришельца/робота). Ручной сдвиг детей с делением на
// scaleApplied неверен если у детей свой scale/rotation. Надёжно:
// вставляем промежуточный pivot между root и моделью и смещаем pivot
// на -localCenter (через инверсию world-матрицы root — точно при
// любом scale/rotation).
const worldCenter = new Vector3(
(minX + maxX) / 2, // центр X
minY, // низ Y (модель «садится» на ноги)
(minZ + maxZ) / 2 // центр Z
);
// world-центр → локальные координаты root
const invRoot = root.getWorldMatrix().clone().invert();
const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
const pivot = new TransformNode('playerModelPivot', this.scene);
pivot.parent = root;
pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
// Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
for (const ch of root.getChildren().slice()) {
if (ch === pivot) continue;
ch.parent = pivot;
}
// Сохраняем размеры для настраиваемого AABB и камеры.
// hipHeight из манифеста — приоритетно; иначе берём низ модели.
this._nonHumanoidBox = { w, h, d };
this._modelBaseHeight = h;
// AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
// Ограничиваем разумными пределами чтобы не проваливаться/застревать.
this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
const halfH = Math.max(0.3, Math.min(1.0, h / 2));
this.HALF_H = halfH;
this.HALF_H_NORMAL = halfH;
this.EYE_HEIGHT = halfH * 0.7;
// eslint-disable-next-line no-console
console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
}
}
/**
* Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
* костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
* беге + наклон в воздухе. Вызывается каждый кадр из _tick.
* baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
*/
_animateNonHumanoidMesh(dt) {
const root = this._modelRoot;
if (!root) return;
const t = (typeof performance !== 'undefined' && performance.now)
? performance.now() / 1000 : Date.now() / 1000;
const speed = this._lastFrameSpeed || 0;
// Базовое вращение по yaw уже выставляет _tick (он крутит модель под
// направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
// поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
let bobY = 0, tiltX = 0;
if (!this._isGrounded) {
tiltX = 0.2; // в воздухе — нос вверх
} else if (speed > 0.1) {
const bobFreq = 8 * Math.min(2, speed / 4);
bobY = Math.sin(t * bobFreq) * 0.06;
tiltX = Math.min(speed * 0.04, 0.13);
} else {
bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
}
// Применяем поверх позиции, которую _tick уже выставил в root.position.y.
root.position.y += bobY;
// tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
root.rotation.x = tiltX;
}
/** AABB игрока пересекает хотя бы один блок-воду. */
_isInWater() {
const bm = this._scene3d?.blockManager;
if (!bm) return false;
// FAST PATH: если на сцене нет водных блоков — точно не в воде.
// Большинство карт (зомби-остров, любые «суховые») — без воды,
// и тройной цикл ниже бесполезно тратит время каждый кадр.
if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
const px = this._pos.x, py = this._pos.y, pz = this._pos.z;
const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D;
// Проверяем клетки которые AABB перекрывает
const gxMin = Math.floor(px - hw + 0.5);
const gxMax = Math.floor(px + hw + 0.5);
const gzMin = Math.floor(pz - hd + 0.5);
const gzMax = Math.floor(pz + hd + 0.5);
const gyMin = Math.floor(py - hh);
const gyMax = Math.floor(py + hh);
for (let gx = gxMin; gx <= gxMax; gx++) {
for (let gy = gyMin; gy <= gyMax; gy++) {
for (let gz = gzMin; gz <= gzMax; gz++) {
const m = bm.blocks.get(`${gx},${gy},${gz}`);
if (m?.metadata?.isWater) return true;
}
}
}
return false;
}
/** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */
_isSubmerged() {
const bm = this._scene3d?.blockManager;
if (!bm) return false;
// FAST PATH: нет воды на сцене — не утопаем.
if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
// Проверяем «голову» — точку чуть ниже верха AABB
const headY = this._pos.y + this.HALF_H - 0.1;
const gx = Math.round(this._pos.x);
const gy = Math.floor(headY);
const gz = Math.round(this._pos.z);
const m = bm.blocks.get(`${gx},${gy},${gz}`);
return !!m?.metadata?.isWater;
}
/** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */
_playFootstep() {
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const duration = 0.08;
// Источник — короткий шумовой буфер
const sampleRate = ctx.sampleRate;
const length = Math.floor(sampleRate * duration);
const buffer = ctx.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум
}
const src = ctx.createBufferSource();
src.buffer = buffer;
// Lowpass для тяжёлого «тук» вместо высокого «шшш»
const lowpass = ctx.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = 350;
lowpass.Q.value = 1.5;
// Envelope (быстрая атака, быстрое затухание)
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.7, now + 0.005);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
src.connect(lowpass).connect(gain).connect(ctx.destination);
src.start(now);
src.stop(now + duration);
} catch (e) {
// ignore — звук не критичен
}
}
/**
* Звук прыжка — мягкий «boing» из двух слоёв:
* 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами»
* 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма»
* Гораздо приятнее старого квадратного восходящего тона.
*/
/**
* Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation.
* Работает только для R15-скинов (Kenney-модели эмоций не имеют).
*/
playEmote(name) {
if (this._isR15 && this._r15Animator) {
return this._r15Animator.playEmote(name);
}
return false;
}
/** Прервать текущую эмоцию персонажа. */
stopEmote() {
if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote();
}
_playJumpSound() {
// Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок
// (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся.
if (typeof this._onJump === 'function') {
try { this._onJump(); } catch (e) { /* ignore */ }
}
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const out = ctx.destination;
// Слой 1: низкий thump
const thumpDur = 0.07;
const thumpOsc = ctx.createOscillator();
thumpOsc.type = 'sine';
thumpOsc.frequency.setValueAtTime(110, now);
thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur);
const thumpGain = ctx.createGain();
thumpGain.gain.setValueAtTime(0, now);
thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005);
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur);
thumpOsc.connect(thumpGain).connect(out);
thumpOsc.start(now);
thumpOsc.stop(now + thumpDur + 0.02);
// Слой 2: «boing» — pitch-down sine
const boingDur = 0.18;
const boingOsc = ctx.createOscillator();
boingOsc.type = 'sine';
boingOsc.frequency.setValueAtTime(720, now + 0.005);
boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur);
const boingGain = ctx.createGain();
boingGain.gain.setValueAtTime(0, now + 0.005);
boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02);
boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur);
// Лёгкое vibrato чтобы было «живее»
const lfo = ctx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 14;
const lfoGain = ctx.createGain();
lfoGain.gain.value = 18; // ±18 Hz
lfo.connect(lfoGain).connect(boingOsc.frequency);
boingOsc.connect(boingGain).connect(out);
boingOsc.start(now + 0.005);
lfo.start(now + 0.005);
boingOsc.stop(now + 0.005 + boingDur + 0.02);
lfo.stop(now + 0.005 + boingDur + 0.02);
} catch (e) { /* ignore */ }
}
/** Звук «бульк» при входе/выходе из воды. */
_playSplashSound() {
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const duration = 0.35;
// Шум с быстро падающим highpass — звук «всплеска»
const length = Math.floor(ctx.sampleRate * duration);
const buf = ctx.createBuffer(1, length, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2;
}
const src = ctx.createBufferSource();
src.buffer = buf;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.setValueAtTime(2000, now);
bp.frequency.exponentialRampToValueAtTime(400, now + duration);
bp.Q.value = 1.5;
const g = ctx.createGain();
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.6, now + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, now + duration);
src.connect(bp).connect(g).connect(ctx.destination);
src.start(now);
src.stop(now + duration);
} catch (e) { /* ignore */ }
}
_playAnim(name) {
if (this._currentAnim === name) return;
const target = this._animations[name];
if (!target) {
// если нужной нет — пробуем idle как fallback
if (name !== 'idle' && this._animations.idle) {
return this._playAnim('idle');
}
return;
}
// === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ ===
// Помогает дебажить вибрацию ног на склонах: если в логе видно
// частое мерцание walk↔jump или idle↔walk — значит onGround или
// isMoving скачет каждые несколько кадров.
const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false };
console.log(`[Anim] ${this._currentAnim || '∅'}${name} `
+ `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`);
// Стопим текущую
if (this._currentAnim && this._animations[this._currentAnim]) {
this._animations[this._currentAnim].stop();
}
target.start(/*loop*/ true, /*speed*/ 1);
this._currentAnim = name;
}
/**
* Остановить режим игры. Освобождает все ресурсы.
*/
stop() {
if (!this._active) return;
this._active = false;
if (this._beforeRender) {
this.scene.unregisterBeforeRender(this._beforeRender);
this._beforeRender = null;
}
for (const { target, type, fn, opts } of this._listeners) {
target.removeEventListener(type, fn, opts);
}
this._listeners = [];
if (document.pointerLockElement === this.canvas) {
document.exitPointerLock();
}
// Останавливаем все анимации
for (const g of Object.values(this._animations)) {
try { g.stop(); } catch (e) { /* ignore */ }
}
this._animations = {};
this._currentAnim = null;
// Удаляем якорь оружия
if (this._weaponAnchor) {
try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ }
this._weaponAnchor = null;
}
// Сбрасываем все override'ы вращения
if (this._meshRotationOverrides) {
this._meshRotationOverrides.clear();
}
// Сброс R15-состояния
if (this._r15BoneOverrides) this._r15BoneOverrides.clear();
this._r15Animator = null;
this._r15Skeleton = null;
this._isR15 = false;
// Удаляем модель
if (this._modelRoot) {
for (const m of this._modelMeshes) {
try { m.dispose(); } catch (e) { /* ignore */ }
}
try { this._modelRoot.dispose(); } catch (e) { /* ignore */ }
this._modelRoot = null;
this._modelMeshes = [];
}
if (this.camera) {
this.camera.dispose();
this.camera = null;
}
if (this._audioCtx) {
try { this._audioCtx.close(); } catch (e) { /* ignore */ }
this._audioCtx = null;
}
}
isActive() {
return this._active;
}
// === ВНУТРЕННЕЕ ===
/** Позиция камеры в мире (зависит от режима first/third/front). */
_computeCameraPos() {
// Виртуальная "визуальная" Y-позиция игрока — учитывает step-up
// оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем»
// высоту чтобы камера не дёргалась рывком при step-up.
const visY = this._pos.y - this._stepUpVisualOffset;
if (this._cameraMode === 'first') {
return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z);
}
if (this._cameraMode === 'sideview') {
// Кубикон Dash: камера сбоку, фиксированный yaw на куб.
// Игрок движется по +X, камера в -Z от него (смотрит на +Z).
// С этого ракурса +X на экране = вправо (как в Geometry Dash).
// Лёгкое смещение camera по X влево от куба — игрок в левой
// трети кадра, впереди видно больше уровня.
return new Vector3(
this._pos.x - 1.5,
visY + SIDEVIEW_HEIGHT,
this._pos.z - SIDEVIEW_DIST
);
}
// forward — направление «куда смотрит игрок» с учётом yaw и pitch
const cosP = Math.cos(this._pitch);
const fx = Math.sin(this._yaw) * cosP;
const fy = -Math.sin(this._pitch);
const fz = Math.cos(this._yaw) * cosP;
const dist = this._thirdDistance;
// Точка «глаз» игрока — отсюда пускаем луч к запланированной
// позиции камеры и сокращаем дистанцию если упёрлись в стену.
const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET;
if (this._cameraMode === 'third') {
const desired = new Vector3(
this._pos.x - fx * dist,
eyeY - fy * dist,
this._pos.z - fz * dist
);
return this._clampCameraToWorld(
this._pos.x, eyeY, this._pos.z, desired
);
}
// 'front' — спереди игрока, направлена назад (на лицо)
const desiredFront = new Vector3(
this._pos.x + fx * dist,
eyeY + fy * dist,
this._pos.z + fz * dist
);
return this._clampCameraToWorld(
this._pos.x, eyeY, this._pos.z, desiredFront
);
}
/**
* Не позволяет камере «проходить» сквозь стены/блоки/примитивы.
* Пускает луч от глаз игрока до запланированной позиции камеры.
* Если на пути есть препятствие — возвращает точку чуть ближе
* (hit.distance - PADDING), чтобы камера прижалась к стене.
*
* Игнорирует:
* - меши без metadata (вспомогательная техника редактора),
* - триггеры (canCollide===false),
* - саму модель игрока,
* - debris/particles.
*/
_clampCameraToWorld(ex, ey, ez, desired) {
if (!this.scene) return desired;
// Вектор от глаз до желаемой камеры.
const dx = desired.x - ex;
const dy = desired.y - ey;
const dz = desired.z - ez;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт
const dir = new Vector3(dx / len, dy / len, dz / len);
const origin = new Vector3(ex, ey, ez);
const ray = new Ray(origin, dir, len);
const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась»
const playerRoot = this._modelRoot;
const pickPred = (mesh) => {
if (!mesh) return false;
if (!mesh.isEnabled || !mesh.isEnabled()) return false;
// Прозрачно-визуальные и technical meshes — пропускаем.
if (mesh.isPickable === false) return false;
const md = mesh.metadata || {};
// Триггеры/невидимки/скриптовые маркеры — не блокируют.
if (md.canCollide === false) return false;
if (md._isTriggerHelper) return false;
// Модель игрока (камера не должна цепляться за собственный меш).
if (playerRoot) {
let n = mesh;
while (n) {
if (n === playerRoot) return false;
n = n.parent;
}
}
// Жидкости (вода/лава) — не блокируют камеру.
if (md._liquidProxy) return false;
return true;
};
let hit = null;
try {
hit = this.scene.pickWithRay(ray, pickPred);
} catch (e) {
return desired;
}
if (!hit || !hit.hit || hit.distance >= len - 0.01) {
return desired;
}
// Сокращаем дистанцию.
const clampedLen = Math.max(0.3, hit.distance - PADDING);
return new Vector3(
ex + dir.x * clampedLen,
ey + dir.y * clampedLen,
ez + dir.z * clampedLen
);
}
// ===== Управление камерой из скрипта (Фаза 5.7) =====
/** Установить угол обзора камеры (FOV) в градусах. */
setCameraFov(degrees) {
if (!this.camera) return;
const d = Number(degrees);
if (!Number.isFinite(d) || d < 10 || d > 130) return;
this.camera.fov = d * Math.PI / 180;
}
/**
* Привязать камеру к объекту — она смотрит на него.
* getTarget — функция, возвращающая {x,y,z} цели.
* opts: { distance, height } — отступ камеры от цели.
*/
cameraFocusOn(getTarget, opts = {}) {
if (typeof getTarget !== 'function') return;
this._cameraOverride = {
mode: 'focus',
getTarget,
distance: Number.isFinite(opts.distance) ? opts.distance : 8,
height: Number.isFinite(opts.height) ? opts.height : 4,
};
}
/**
* Катсцена — плавный пролёт камеры по точкам.
* points — массив {x,y,z} позиций камеры.
* lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию).
* segDuration — секунд на отрезок между точками.
* onDone — колбэк по завершении.
*/
cameraCutscene(points, lookAt, segDuration, onDone) {
if (!Array.isArray(points) || points.length < 2) return;
this._cameraOverride = {
mode: 'cutscene',
points,
lookAt: Array.isArray(lookAt) ? lookAt : [],
segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2,
t: 0, // время от начала
seg: 0, // текущий отрезок
onDone: typeof onDone === 'function' ? onDone : null,
};
}
/** Вернуть камеру под управление игрока. */
cameraReset() {
this._cameraOverride = null;
}
// ===== Задача 14: вождение машины =====
/** Усадить игрока в машину veh (объект из VehicleManager). */
enterVehicle(veh) {
if (!veh) return;
this._inVehicle = veh;
this._vehicleCamMode = 'follow';
veh.driver = 'player';
if (this._codes) this._codes.clear(); // сброс остаточных WASD от ходьбы
this._skinVisibleScripted = false; // спрятать аватар (сидит в машине)
this._startEngineSound();
}
/**
* Звук мотора: низкочастотный РОКОТ (а не воющий тон).
* Бас-пила (40-90 Гц) + отфильтрованный шум для «рыка» + LFO-пульсация
* громкости (имитация тактов двигателя). Частота тактов и фильтр растут
* со скоростью, но базовый тон остаётся низким — не сирена.
*/
_startEngineSound() {
try {
if (!this._audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
this._audioCtx = new Ctx();
}
const ctx = this._audioCtx;
if (ctx.state === 'suspended') ctx.resume();
if (this._engineNodes) return; // уже играет
// 1) Бас-тон мотора (низкий, тёплый).
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = 45; // холостой ход — низко
// 2) Шум через узкий lowpass — «рык» выхлопа.
const bufLen = ctx.sampleRate * 1.0;
const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
const noise = ctx.createBufferSource();
noise.buffer = buf; noise.loop = true;
const noiseLp = ctx.createBiquadFilter();
noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
const noiseGain = ctx.createGain();
noiseGain.gain.value = 0.35;
// Общий lowpass — глушит верха, чтобы не свистело.
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
// 3) LFO-пульсация громкости (такты двигателя ~12 Гц на холостых).
const lfo = ctx.createOscillator();
lfo.type = 'sine'; lfo.frequency.value = 12;
const lfoGain = ctx.createGain();
lfoGain.gain.value = 0.18; // глубина пульсации
const gain = ctx.createGain();
gain.gain.value = 0.05; // общая громкость (растёт со скоростью)
osc.connect(lp);
noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
lp.connect(gain); gain.connect(ctx.destination);
// LFO модулирует gain.gain вокруг базового значения.
lfo.connect(lfoGain); lfoGain.connect(gain.gain);
osc.start(); noise.start(); lfo.start();
this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
} catch (e) { /* ignore */ }
}
_updateEngineSound(speedMs, maxSpeed) {
const n = this._engineNodes;
if (!n) return;
try {
const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
const ctx = this._audioCtx; const t = ctx.currentTime;
// Базовый тон остаётся НИЗКИМ: 45 Гц холостые → ~95 Гц на максималке.
n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
// Частота тактов (LFO): 12 Гц холостые → ~45 Гц на ходу (плотнее рокот).
n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
// Фильтры чуть открываются — больше «рыка», но без визга.
n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
// Громкость: 0.05 холостые → 0.13 в движении.
n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
} catch (e) { /* ignore */ }
}
_stopEngineSound() {
const n = this._engineNodes;
if (!n) return;
try {
const t = this._audioCtx.currentTime;
n.gain.gain.setTargetAtTime(0, t, 0.05);
n.osc.stop(t + 0.2);
n.noise.stop(t + 0.2);
n.lfo.stop(t + 0.2);
} catch (e) { /* ignore */ }
this._engineNodes = null;
}
/** Высадить игрока из машины (вернуть ходьбу). */
exitVehicle() {
const veh = this._inVehicle;
this._inVehicle = null;
if (veh) {
veh.driver = null;
// Поставить игрока сбоку от машины.
try {
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
this._vy = 0;
} catch (e) {}
}
this._stopEngineSound();
this._skinVisibleScripted = true;
// Вернуть видимость аватара.
if (this._modelMeshes) {
for (const m of this._modelMeshes) {
if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
}
}
}
/** Циклически сменить режим камеры машины (клавиша V). */
cycleVehicleCamera() {
const modes = ['follow', 'hood', 'cinematic'];
const i = modes.indexOf(this._vehicleCamMode || 'follow');
this._vehicleCamMode = modes[(i + 1) % modes.length];
}
/** Шаг вождения: читаем WASD, рулим машиной, ставим camera follow/hood. */
_tickVehicle(dt) {
const veh = this._inVehicle;
if (!veh || !this._scene3d?.vehicleManager) return;
// Скрываем аватар игрока (сидит в машине) — каждый кадр, т.к. обычный
// хвост _tick (где применяется _skinVisibleScripted) пропускается из-за return.
if (this._modelMeshes) {
for (const m of this._modelMeshes) {
if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
}
}
const c = this._codes;
const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
const handbrake = c.has('Space');
this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
const res = this._scene3d.vehicleManager.tickVehicle(veh, dt);
this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
// Упали в бездну на машине → выйти + респавн на точке старта.
if (res && res.fellOut) {
this.exitVehicle();
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
return;
}
// Синхронизируем позицию игрока с машиной (для квестов/выхода).
try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
// Камера машины.
if (!this.camera) return;
const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
const cp = veh.pos;
const mode = this._vehicleCamMode || 'follow';
let camPos, camTarget;
if (mode === 'hood') {
camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
} else if (mode === 'cinematic') {
const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
} else { // follow
camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
}
// Плавный lerp позиции камеры (без рывков на поворотах).
const k = Math.min(1, dt * 6);
this.camera.position.set(
this.camera.position.x + (camPos.x - this.camera.position.x) * k,
this.camera.position.y + (camPos.y - this.camera.position.y) * k,
this.camera.position.z + (camPos.z - this.camera.position.z) * k,
);
try { this.camera.setTarget(camTarget); } catch (e) {}
}
/** Применить активный режим камеры скрипта (вызывается в _tick). */
_applyCameraOverride(dt) {
const o = this._cameraOverride;
if (!o || !this.camera) return;
if (o.mode === 'focus') {
const t = o.getTarget();
if (!t) return;
// Камера позади-сверху цели, смотрит на неё.
this.camera.position.set(t.x, t.y + o.height, t.z + o.distance);
this.camera.setTarget(new Vector3(t.x, t.y, t.z));
} else if (o.mode === 'cutscene') {
// Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB)
// dt может скакнуть до 0.5-2с — тогда катсцена «проматывается»
// за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт
// ровно свою длительность независимо от лагов.
o.t += Math.min(dt, 1 / 30);
const segCount = o.points.length - 1;
// Прогресс по текущему отрезку [0..1].
let local = o.t / o.segDuration;
let seg = Math.floor(o.t / o.segDuration);
if (seg >= segCount) {
// Катсцена завершена — встаём на последнюю точку.
const last = o.points[o.points.length - 1];
this.camera.position.set(last.x, last.y, last.z);
const lookLast = o.lookAt[o.lookAt.length - 1];
if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z));
const cb = o.onDone;
this._cameraOverride = null;
if (cb) { try { cb(); } catch (e) { /* ignore */ } }
return;
}
local = local - seg; // дробная часть = прогресс отрезка
// Сглаживание (ease-in-out) — плавный пролёт.
const k = local < 0.5
? 2 * local * local
: 1 - Math.pow(-2 * local + 2, 2) / 2;
const a = o.points[seg], b = o.points[seg + 1];
this.camera.position.set(
a.x + (b.x - a.x) * k,
a.y + (b.y - a.y) * k,
a.z + (b.z - a.z) * k,
);
// Точка взгляда — интерполяция между соседними lookAt.
const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg];
if (la && lb) {
this.camera.setTarget(new Vector3(
la.x + (lb.x - la.x) * k,
la.y + (lb.y - la.y) * k,
la.z + (lb.z - la.z) * k,
));
}
}
}
/**
* Применить текущий режим камеры:
* - В 1st person скрываем модель игрока (видим только сцену)
* - В 3rd person и front показываем
*/
_applyCameraMode() {
const visible = this._cameraMode !== 'first';
for (const m of this._modelMeshes) {
m.setEnabled(visible);
}
// Сообщаем оружию что режим камеры сменился — чтобы перепарентить
// view-model (камера в 1-st, модель игрока в 3-rd).
if (this._scene3d?.weapons?.onCameraModeChange) {
this._scene3d.weapons.onCameraModeChange(this._cameraMode);
}
}
/**
* УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели.
*
* Установить override-rotation для именованного меша поверх анимации.
* Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion,
* потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет
* наши углы Эйлера.
*
* meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...).
* Без префикса 'player_'.
* rotation — Vector3 углов Эйлера (rad). null → снять override.
*
* Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты).
*/
setMeshRotationOverride(meshName, rotation) {
// R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт
// этот метод для позы/замаха — переадресуем на override кости.
// R15Animator каждый кадр ставит rest+анимацию; override кости
// применяется поверх в _applyR15BoneOverrides() после update().
if (this._isR15) {
if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map();
// Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость.
const lower = (meshName || '').toLowerCase();
const logical = (lower.includes('right')) ? 'RightUpperArm'
: (lower.includes('left')) ? 'LeftUpperArm'
: 'RightUpperArm';
if (rotation == null) {
this._r15BoneOverrides.delete(logical);
} else {
this._r15BoneOverrides.set(logical,
rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
}
return;
}
if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map();
if (rotation == null) {
this._meshRotationOverrides.delete(meshName);
} else {
this._meshRotationOverrides.set(meshName,
rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
}
}
/**
* Применить override-повороты костей R15 поверх процедурной анимации.
* Вызывается после R15Animator.update(). Используется WeaponSystem
* для позы руки с оружием / melee-замаха.
*/
_applyR15BoneOverrides() {
const map = this._r15BoneOverrides;
if (!map || map.size === 0 || !this._r15Skeleton) return;
for (const [logical, rot] of map.entries()) {
const bone = this._r15Skeleton.resolveBone(logical);
if (!bone) continue;
// Override задаётся как абсолютный локальный поворот кости
// (Эйлер). Перекрывает то, что поставил аниматор этим кадром.
const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z);
bone.setRotationQuaternion(q, Space.LOCAL);
}
}
/** Получить меш модели по короткому имени (без префикса 'player_'). */
getModelMesh(meshName) {
if (!this._modelMeshes) return null;
const target = `player_${meshName}`;
return this._modelMeshes.find(m => m.name === target) || null;
}
/**
* Применить все активные override'ы. Вызывается каждый кадр в _tick
* ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после
* _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его
* каждый кадр и пишем в .rotation.
*/
_applyMeshRotationOverrides() {
const map = this._meshRotationOverrides;
if (!map || map.size === 0) return;
for (const [meshName, rot] of map.entries()) {
const mesh = this.getModelMesh(meshName);
if (!mesh) continue;
if (mesh.rotationQuaternion) {
mesh.rotationQuaternion = null;
}
mesh.rotation.x = rot.x;
mesh.rotation.y = rot.y;
mesh.rotation.z = rot.z;
}
}
/**
* Включить/выключить «позу с оружием».
* Делает 2 вещи независимо:
* 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney).
* 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает
* с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0,
* и дуло автоматически смотрит вперёд персонажа.
*
* Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять
* ориентацию повёрнутого меша руки.
*/
_updateExtendedArm(hasWeapon) {
// === R15-скин: якорь оружия на кости RightHand ===
// У R15 нет меша-руки — есть кость. Якорь привязываем к кости
// через attachToBone: оружие следует за рукой при анимации.
if (this._isR15 && this._r15Skeleton) {
const showWeapon = hasWeapon && this._cameraMode !== 'first';
if (showWeapon && !this._weaponAnchor) {
const handBone = this._r15Skeleton.resolveBone('RightHand');
const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0];
if (handBone && skinMesh) {
this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
// attachToBone — якорь следует за костью каждый кадр.
this._weaponAnchor.attachToBone(handBone, skinMesh);
// Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье.
this._weaponAnchor.position.set(0, 0.05, 0.1);
}
}
if (this._weaponAnchor) {
this._weaponAnchor.setEnabled(showWeapon);
}
return;
}
const armMesh = this._rightArmMeshes?.[0];
if (!armMesh) return;
const meshName = (armMesh.name || '').replace(/^player_/, '');
const showWeapon = hasWeapon && this._cameraMode !== 'first';
// 1) Поза руки через override
if (showWeapon) {
this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0));
} else {
this.setMeshRotationOverride(meshName, null);
}
// 2) ChIstый якорь для оружия — TransformNode на плече персонажа,
// ориентация совпадает с _modelRoot (без поворотов).
if (showWeapon && !this._weaponAnchor && this._modelRoot) {
this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
this._weaponAnchor.parent = this._modelRoot;
// Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша.
// Плечо: y = origin + 0.7 (выше).
// X: сдвигаем чуть наружу (ещё правее).
const sx = -(armMesh.position?.x ?? -0.4) + 0.15;
const sy = (armMesh.position?.y ?? 1.1) + 0.7;
const sz = (armMesh.position?.z ?? 0) + 0.95;
this._weaponAnchor.position.set(sx, sy, sz);
}
if (this._weaponAnchor) {
this._weaponAnchor.setEnabled(showWeapon);
}
}
getWeaponAnchor() { return this._weaponAnchor || null; }
/** Цикл first ↔ third. */
_toggleCameraMode() {
const idx = CAMERA_MODES.indexOf(this._cameraMode);
this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
this._applyCameraMode();
// При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock)
if (this._cameraMode === 'first') {
this._requestPointerLockSafe();
} else if (!this._shiftLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
this._applyCursorVisibility?.();
}
/** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
* всегда лицом к камере, камера через плечо).
*/
setShiftLock(on) {
this._shiftLock = !!on;
if (this._shiftLock) {
// Запросить pointer-lock — курсор в центре
this._requestPointerLockSafe();
} else {
// Снять lock если он есть и нет других причин держать (first/sideview)
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview'
);
if (!needPermLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
}
this._applyCursorVisibility?.();
}
isShiftLock() { return !!this._shiftLock; }
/** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
* Не блокирует Esc/Tab/Enter (нужны для GUI).
* Также сбрасывает накопленные клавиши чтобы движение остановилось. */
setInputBlocked(blocked) {
this._inputBlocked = !!blocked;
if (this._inputBlocked) {
try { this._codes?.clear(); } catch (e) {}
this._shift = false;
// Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
try {
if (document.pointerLockElement === this.canvas) document.exitPointerLock();
} catch (e) {}
}
}
isInputBlocked() { return !!this._inputBlocked; }
/** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
setCameraFrozen(frozen) {
this._cameraFrozen = !!frozen;
}
isCameraFrozen() { return !!this._cameraFrozen; }
/** Задача 04: снимок состояния камеры — для восстановления после модала. */
captureCameraState() {
return {
yaw: this._yaw,
pitch: this._pitch,
cameraMode: this._cameraMode,
thirdDistance: this._thirdDistance,
fov: this.scene?.activeCamera?.fov,
playerPos: this._pos ? {
x: this._pos.x, y: this._pos.y, z: this._pos.z
} : null,
};
}
/** Задача 04: восстановить состояние камеры из снимка. */
restoreCameraState(s) {
if (!s) return;
if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
if (s.cameraMode) {
this._cameraMode = s.cameraMode;
try { this._applyCameraMode?.(); } catch (e) {}
}
if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
this.scene.activeCamera.fov = s.fov;
}
}
/** Задача 04: камера-фокус на reference (cube/npc/cam-target).
* ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
* Использует уже существующий механизм camera.focus в GameRuntime, но
* здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
* и зум на distance. */
focusOnTarget(ref, opts) {
opts = opts || {};
const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
const height = Number.isFinite(opts.height) ? opts.height : 3;
const fov = Number.isFinite(opts.fov) ? opts.fov : null;
let target = null;
if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
target = ref;
} else {
const m = this._resolveTargetMesh(ref);
if (m) {
const p = m.getAbsolutePosition?.() || m.position;
target = { x: p.x, y: p.y, z: p.z };
}
}
if (!target) return;
// Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
// Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
if (!this._pos) return;
const dx = target.x - this._pos.x;
const dz = target.z - this._pos.z;
const dy = target.y - this._pos.y;
const horiz = Math.hypot(dx, dz);
this._yaw = Math.atan2(dx, dz);
this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
this._thirdDistance = distance;
if (this._cameraMode !== 'third') {
this._cameraMode = 'third';
try { this._applyCameraMode?.(); } catch (e) {}
}
if (fov && this.scene?.activeCamera) {
this.scene.activeCamera.fov = fov * Math.PI / 180;
}
}
_resolveTargetMesh(ref) {
if (!ref) return null;
if (ref.getScene && typeof ref.getScene === 'function') return ref;
const sc = this._scene3d || this.scene3d;
const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
if (!idStr || !sc) return null;
const tries = [
() => sc.primitiveManager?.getMesh?.(idStr),
() => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
() => sc.scene?.getMeshByName?.(idStr),
() => sc.npcManager?.getMeshes?.(idStr)?.[0],
];
for (const fn of tries) {
try { const r = fn(); if (r) return r; } catch (e) {}
}
return null;
}
/** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
setCameraZoom(distance) {
const d = Number(distance);
if (!Number.isFinite(d)) return;
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
Math.min(this.THIRD_DISTANCE_MAX, d));
// Авто-переход third↔first если пересекли порог
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
&& this._cameraMode === 'third') {
this._cameraMode = 'first';
this._applyCameraMode?.();
this._requestPointerLockSafe();
} else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
&& this._cameraMode === 'first' && !this._lockFirstPerson) {
this._cameraMode = 'third';
this._applyCameraMode?.();
if (!this._shiftLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
}
}
/** Установить границы зума колеса. */
setCameraZoomLimits(min, max) {
const mn = Number(min), mx = Number(max);
if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
// Перекламп текущей дистанции
this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
}
/** Поведение мыши: default / lockcenter / lockcurrent.
* default — свободный курсор (стандартный browser cursor).
* lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y).
* lockcurrent — pointer-lock, но без скрытия (визуально как default,
* реально движение отслеживается через movementX/Y).
*/
setMouseBehavior(mode) {
if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
this._mouseBehavior = mode;
if (mode === 'default') {
// Снимаем lock если ничто другое не требует его
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (!needPermLock && document.pointerLockElement === this.canvas) {
try { document.exitPointerLock(); } catch (e) {}
}
} else {
this._requestPointerLockSafe();
}
this._applyCursorVisibility?.();
}
/** Видимость курсора (для third без lock). */
setMouseIconVisible(visible) {
this._mouseIconVisible = !!visible;
this._applyCursorVisibility?.();
}
_setupInput() {
const canvas = this.canvas;
const onCanvasClick = () => {
// В UI-режиме клик не перехватывает мышь.
if (this._uiCursorMode) return;
if (!this._active) return;
// Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор —
// курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем
// ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/
// sideview/shiftLock), и только если по какой-то причине lock сняли
// (например, юзер нажал Esc в first-режиме — надо вернуть lock).
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (!needPermLock) return;
if (document.pointerLockElement !== canvas) {
try {
const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) { /* ignore */ }
}
};
canvas.addEventListener('click', onCanvasClick);
// === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
// Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру.
// Отпустил → курсор вернулся на ту же позицию (браузер сам ставит).
const onCanvasMouseDownGlobal = (e) => {
if (!this._active || this._uiCursorMode) return;
if (e.button !== 2) return; // только ПКМ
// В режимах с постоянным lock'ом ПКМ ничего не делает
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (needPermLock) return;
// Запрашиваем lock — теперь mouseMove будет крутить камеру.
this._rmbHeld = true;
if (document.pointerLockElement !== canvas) {
try {
const p = canvas.requestPointerLock?.();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (err) { /* ignore */ }
}
e.preventDefault();
};
const onWindowMouseUpGlobal = (e) => {
if (e.button !== 2) return;
if (!this._rmbHeld) return;
this._rmbHeld = false;
// Отпускаем lock только если он был включён нами для orbit-камеры
// (т.е. сейчас НЕ режим с постоянным lock).
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (needPermLock) return;
if (document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) { /* ignore */ }
}
};
canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
window.addEventListener('mouseup', onWindowMouseUpGlobal);
// Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger).
canvas.addEventListener('contextmenu', (e) => {
if (this._active) e.preventDefault();
});
// === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
const onCanvasMouseDown = (e) => {
if (!this._uiCursorMode) return;
if (typeof this._uiMouseDownCb !== 'function') return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ }
}
};
const onCanvasMouseUp = (e) => {
if (!this._uiCursorMode) return;
if (typeof this._uiMouseUpCb !== 'function') return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ }
};
canvas.addEventListener('mousedown', onCanvasMouseDown);
// mouseup ловим на document — мышь могла уйти за пределы канваса
document.addEventListener('mouseup', onCanvasMouseUp);
const onMouseMove = (e) => {
// === UI-режим: транслируем нормализованные [0..1] координаты ===
// подписчику (Worker через GameRuntime). Используется для drag-игр
// типа Дальгона.
if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
// Кидаем только если внутри канваса
if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ }
}
}
if (document.pointerLockElement !== canvas) return;
// Кубикон Dash: в sideview мышь не вращает камеру.
if (this._cameraMode === 'sideview') return;
// Задача 04: модал с freezeCamera — мышь не вращает.
if (this._cameraFrozen) return;
this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
this._pitch += e.movementY * this.MOUSE_SENSITIVITY;
const lim = Math.PI / 2 - 0.05;
if (this._pitch > lim) this._pitch = lim;
if (this._pitch < -lim) this._pitch = -lim;
};
document.addEventListener('mousemove', onMouseMove);
// Колесо: zoom в third + авто-переключение third ↔ first.
// Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person
// (с pointer-lock). Колесо наружу из first → возврат в third.
const onWheel = (e) => {
if (!this._active) return;
if (this._cameraMode === 'sideview') return;
// Задача 04: модал с freezeCamera — колесо не зумит.
if (this._cameraFrozen) { e.preventDefault(); return; }
// В first-режиме колесо вверх НЕ работает (если lockfirst), вниз
// выходит обратно в third (если zoomable first, не lockfirst).
if (this._cameraMode === 'first') {
if (this._lockFirstPerson) { e.preventDefault(); return; }
if (e.deltaY > 0) {
// Колесо вниз → отдалить → переход в third
this._cameraMode = 'third';
this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5;
this._applyCameraMode?.();
// Снять pointer-lock — в third без shift-lock курсор виден
if (!this._shiftLock && document.pointerLockElement === canvas) {
try { document.exitPointerLock(); } catch (err) {}
}
}
e.preventDefault();
return;
}
if (this._cameraMode !== 'third') return;
// Шаг зума — пропорционален текущей дистанции (экспоненциальный фил)
const step = Math.max(0.3, this._thirdDistance * 0.15);
this._thirdDistance += Math.sign(e.deltaY) * step;
if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
// Авто-переход в first при близком зуме (Roblox-style)
if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) {
this._cameraMode = 'first';
this._applyCameraMode?.();
// Запросить pointer-lock — first всегда залочен
if (!this._shiftLock && document.pointerLockElement !== canvas) {
this._requestPointerLockSafe();
}
}
e.preventDefault();
};
canvas.addEventListener('wheel', onWheel, { passive: false });
let wasLocked = false;
const onPointerLockChange = () => {
const locked = document.pointerLockElement === canvas;
if (locked) {
wasLocked = true;
this._rmbHeld = true; // если попал в lock — ПКМ удерживается
} else if (wasLocked && this._active) {
// pointer-lock снят. Причин три:
// 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui'))
// 2) ПКМ отпущена в third-person (orbit-камера завершена)
// 3) Esc → выход из Play (если был в first/lockfirst/sideview)
this._rmbHeld = false;
if (this._uiCursorMode) {
this._applyCursorVisibility();
return;
}
const needPermLock = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._cameraMode === 'sideview' ||
this._shiftLock
);
if (needPermLock) {
// Был режим с постоянным lock'ом и его сняли → Esc → выход
if (this._onExitRequest) this._onExitRequest();
} else {
// Third-person: пользователь просто отпустил ПКМ. Курсор
// возвращается там же где был — это нормально, остаёмся в Play.
this._applyCursorVisibility();
}
}
};
document.addEventListener('pointerlockchange', onPointerLockChange);
const isTypingTarget = (target) => {
if (!target) return false;
const tag = (target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
return !!target.isContentEditable;
};
const onKeyDown = (e) => {
if (!this._active) return;
if (isTypingTarget(e.target)) return;
// Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
// (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
// в third (без pointer-lock) сразу выходил из Play.
if (e.code === 'Escape') {
if (this._onExitRequest) {
this._onExitRequest();
return;
}
}
// Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
// но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
// и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
// Глотаем preventDefault только для игровых клавиш
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
return;
}
this._codes.add(e.code);
if (e.shiftKey) this._shift = true;
// Задача 14: в машине — V меняет камеру, E выходит.
if (this._inVehicle) {
if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
else if (e.code === 'KeyE') {
const veh = this._inVehicle;
this.exitVehicle();
if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
}
}
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
if (e.code === 'KeyC') {
const inGdMode = (this._autoRunSpeed || 0) > 0
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
if (!inGdMode) this._toggleCameraMode();
}
// L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
// поэтому переназначено на L). Курсор центрируется, корпус всегда
// лицом к камере, камера через плечо.
if (e.code === 'KeyL') {
this.setShiftLock(!this._shiftLock);
}
// B — встроенный магазин скинов (задача 07). Открывается только если
// включён в проекте (scene.skins.shopVisible). Toggle.
if (e.code === 'KeyB' && !this._inputBlocked) {
try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
}
// Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
if (e.code === 'Tab') {
e.preventDefault();
this.setUiCursorMode(!this._uiCursorMode);
}
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
}
// В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус),
// Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек.
const inGdMode = (this._autoRunSpeed || 0) > 0
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) {
e.preventDefault();
}
};
const onKeyUp = (e) => {
if (isTypingTarget(e.target)) return;
this._codes.delete(e.code);
if (!e.shiftKey) this._shift = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
const onBlur = () => {
this._codes.clear();
this._shift = false;
};
window.addEventListener('blur', onBlur);
this._listeners = [
{ target: canvas, type: 'click', fn: onCanvasClick },
{ target: canvas, type: 'wheel', fn: onWheel,
opts: { passive: false } },
{ target: document, type: 'mousemove', fn: onMouseMove },
{ target: document, type: 'pointerlockchange', fn: onPointerLockChange },
{ target: window, type: 'keydown', fn: onKeyDown },
{ target: window, type: 'keyup', fn: onKeyUp },
{ target: window, type: 'blur', fn: onBlur },
];
}
_tick() {
// dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая
// логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж
// не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1
// (max 10 кадров/сек физики). Этого хватает чтобы движение было
// плавным даже на 5 FPS — просто чуть рывками.
let dt = this.scene.getEngine().getDeltaTime() / 1000;
if (dt <= 0) return;
if (dt > 0.1) dt = 0.1;
// === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
if (this._inVehicle) {
try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
return;
}
// === Присед: по Ctrl на десктопе, или через мобильную кнопку
// (которая шлёт keydown 'ControlLeft'). C — НЕ используется
// (это смена вида в Babylon).
// В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику
// (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship).
const inGdMode = (this._autoRunSpeed || 0) > 0
|| this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
const wantCrouch = !inGdMode && this._codes
&& (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
if (wantCrouch && !this._crouching) {
this._crouching = true;
const dH = this.HALF_H_CROUCH - this.HALF_H;
this.HALF_H = this.HALF_H_CROUCH;
if (this._pos) this._pos.y += dH;
this._crouchEnterPending = true;
this._crouchTransitionUntil = Date.now() + 600;
} 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;
this._crouchExitPending = true;
this._crouchTransitionUntil = Date.now() + 600;
}
// === Горизонтальное движение ===
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));
// Crouch имеет ПРИОРИТЕТ над sprint
const isSprinting = this._shift && !this._crouching;
const crouchMult = this._crouching ? 0.45 : 1;
const speedMult = (isSprinting ? this.SPRINT_MULT : 1) * crouchMult;
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);
// Crouch Y-drop для Mixamo (см. rublox-player PlayerController.js).
let crouchYDrop = 0;
if (this._crouching && this._mixamoAnimator) {
const ms = this._mixamoAnimator._currentState;
if (ms === 'crouch_idle') crouchYDrop = 0.45;
else if (ms === 'crouch_walk') crouchYDrop = 0.25;
else if (ms === 'crouch_enter' || ms === 'crouch_to_stand') crouchYDrop = 0.30;
else crouchYDrop = 0.30;
}
this._modelRoot.position.set(
this._pos.x + fx * fwdShift,
this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset - crouchYDrop,
this._pos.z + fz * fwdShift
);
// Поворот модели:
// - на суше: направление РЕАЛЬНОГО движения (как было).
// - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
// двигает тело вбок без вращения, как на суше при first-person.
if (inWater) {
const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const maxStep = this.MODEL_TURN_SPEED * dt * 2;
if (Math.abs(diff) <= maxStep) {
this._modelYaw = targetYaw;
} else {
this._modelYaw += Math.sign(diff) * maxStep;
}
} else {
// Roblox-style: в first/lockfirst/shiftLock корпус мгновенно
// следует за yaw камеры (AutoRotate привязан к камере).
// В third — корпус доворачивается под РЕАЛЬНОЕ направление движения.
const followCamera = (
this._cameraMode === 'first' ||
this._cameraMode === 'lockfirst' ||
this._shiftLock
);
if (followCamera) {
const targetYaw = this._yaw;
let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе
if (Math.abs(diff) <= maxStep) {
this._modelYaw = targetYaw;
} else {
this._modelYaw += Math.sign(diff) * maxStep;
}
} else {
const dxReal = this._pos.x - beforeX;
const dzReal = this._pos.z - beforeZ;
const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
if (movedHorizontal) {
const targetYaw = Math.atan2(dxReal, dzReal);
let diff = targetYaw - this._modelYaw;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const maxStep = this.MODEL_TURN_SPEED * dt;
if (Math.abs(diff) <= maxStep) {
this._modelYaw = targetYaw;
} else {
this._modelYaw += Math.sign(diff) * maxStep;
}
}
}
}
// Применяем yaw + swim-tilt.
// rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит
// НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift).
this._modelRoot.rotation.y = this._modelYaw;
this._modelRoot.rotation.x = nextTilt;
// В воде также добавляем лёгкое покачивание по Z (как волна тела)
if (inWater) {
const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15;
this._modelRoot.rotation.z = wobble;
} else if (this._cameraMode === 'sideview') {
// Кубикон Dash: в воздухе куб крутится назад (по часовой если
// смотреть с -Z), на земле плавно возвращается в 0.
// Скорость подобрана так чтобы между прыжками куб успевал
// совершить ~1 оборот.
const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с
if (!result.onGround) {
this._dashSpinAngle -= SPIN_SPEED * dt;
} else {
// Дотягиваем до ближайшего кратного 2π (т.е. визуально 0)
const TAU = Math.PI * 2;
const target = Math.round(this._dashSpinAngle / TAU) * TAU;
const diff = target - this._dashSpinAngle;
const snapStep = SPIN_SPEED * 1.5 * dt;
if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target;
else this._dashSpinAngle += Math.sign(diff) * snapStep;
}
this._modelRoot.rotation.z = this._dashSpinAngle;
} else {
this._modelRoot.rotation.z = 0;
}
// Поза с оружием — обновляем флаг каждый кадр (на случай смены)
const hasWeapon = !!this._scene3d?.weapons?._equipped;
this._updateExtendedArm(hasWeapon);
// Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций.
// Анимация Kenney пишет в rotationQuaternion на меше — обнуляем
// и пишем свои углы Эйлера. Это ключевой момент: вызывается
// каждый кадр, поэтому override переживает анимацию.
this._applyMeshRotationOverrides();
}
// Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)).
// Применяем каждый кадр — на случай если меши только что асинхронно
// загрузились, либо _applyCameraMode перезаписал enabled=true.
if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) {
for (let i = 0; i < this._modelMeshes.length; i++) {
const m = this._modelMeshes[i];
if (m && m.isEnabled && m.isEnabled() && m.setEnabled) {
try { m.setEnabled(false); } catch (e) {}
}
}
}
// Тик распадающихся кусков игрока (после смерти)
this._tickDebris(dt);
// === Анимации ===
// Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
this._isGrounded = !!result.onGround;
// Non-humanoid single-mesh скин: костей нет — анимируем процедурно
// (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
this._animateNonHumanoidMesh(dt);
return;
}
// Mixamo-скин: AnimationGroup для каждого состояния (idle/walk/run/jump/fall
// + crouch_idle/crouch_walk). Грузятся отдельными GLB.
if (this._mixamoAnimator) {
let mState;
const now = Date.now();
const inCrouchTransition = this._crouchTransitionUntil
&& now < this._crouchTransitionUntil;
if (!result.onGround) {
mState = (this._vy > 0.5) ? 'jump' : 'fall';
this._crouchEnterPending = false;
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
} else if (this._crouchEnterPending && inCrouchTransition && !isMoving) {
mState = 'crouch_enter';
} else if (this._crouchExitPending && inCrouchTransition && !isMoving) {
mState = 'crouch_to_stand';
} else if (this._crouching) {
this._crouchEnterPending = false;
this._crouchExitPending = false;
mState = isMoving ? 'crouch_walk' : 'crouch_idle';
} else if (inWater) {
mState = isMoving ? 'walk' : 'idle';
} else if (isMoving) {
this._crouchExitPending = false;
this._crouchTransitionUntil = 0;
mState = isSprinting ? 'run' : 'walk';
} else {
this._crouchExitPending = false;
mState = 'idle';
}
this._mixamoAnimator.setState(mState);
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);
}
}