diff --git a/src/api/Kubikon3DService.js b/src/api/Kubikon3DService.js
index 144d4b2..f080cd6 100644
--- a/src/api/Kubikon3DService.js
+++ b/src/api/Kubikon3DService.js
@@ -193,8 +193,9 @@ export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
},
});
-export const incrementPlay = (id) =>
- api.post(`/kubikon3d/projects/${id}/play`);
+export const incrementPlay = (id, userId) =>
+ api.post(`/kubikon3d/projects/${id}/play`,
+ userId ? { user_id: userId } : {});
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
* голос другого типа — переключает. */
diff --git a/src/community/docsGames.js b/src/community/docsGames.js
index 5a6aabd..1f69526 100644
--- a/src/community/docsGames.js
+++ b/src/community/docsGames.js
@@ -348,4 +348,9 @@ export const GAMES = [
desc: 'Живое 3D-меню как в топ-играх: камера кинематографично облетает премиум-машину в гараже, справа патч-ноуты, снизу кнопка ИГРАТЬ → переход в саму игру.',
mechanics: ['game.mainMenu.show / hide', 'cinematic-камера (waypoints/orbit)', 'патч-ноуты + логотип + кнопка ИГРАТЬ', 'блок управления + фоновая музыка', 'onPlay → loading.transition → gameplay', 'GLB-модель машины (Kenney car-kit)'],
previewShot: 'guide-garage-scene.png', openProjectId: 2434, ready: true },
+ { id: 'guide-taxisim', num: 61, group: 'g5', stars: 3, icon: 'car',
+ title: 'Такси-симулятор — садись за руль',
+ desc: 'Полноценные машины: подходишь, держишь F — садишься за руль, WASD рулят, камера следует за авто, спидометр снизу. E — выйти. Готовые 3D-модели машин.',
+ mechanics: ['game.scene.spawn(\'vehicle:car\')', 'аркадная физика (газ/руль/тормоз)', 'hold-F вход / E выход', 'камера за машиной (V меняет)', 'HUD водителя (спидометр+передача)', 'onVehicleEnter/onVehicleExit'],
+ previewShot: 'guide-taxisim-scene.png', openProjectId: 2436, ready: true },
];
diff --git a/src/community/docsLessons.jsx b/src/community/docsLessons.jsx
index 8b553c2..2001a36 100644
--- a/src/community/docsLessons.jsx
+++ b/src/community/docsLessons.jsx
@@ -8485,6 +8485,117 @@ step();`}
),
},
+ 'guide-taxisim': {
+ body: (
+ <>
+
Что получится
+
+ Настоящие машины, на которых можно ездить. Подходишь к
+ автомобилю → над ним появляется подсказка «[F] Enter», держишь
+ F → садишься за руль. WASD рулят (газ/тормоз/задний ход +
+ повороты), камера плавно следует за машиной, снизу — спидометр
+ с передачей (D/R/N). V меняет ракурс камеры, E — выйти.
+ Машина сталкивается со стенами и столбами. Это основа таксопарков,
+ гонок, доставки — 30% жанров Roblox держатся на транспорте.
+
+
+
+
+
Чему научишься
+
+
game.scene.spawn('vehicle:car', opts) — создать машину:
+ модель, цвет, имя (в подсказке), параметры (скорость/руль/тормоз);
+
вход/выход — у машины автоматически появляется сиденье:
+ подошёл → держишь F → за рулём; E — выйти;
+
аркадная физика — газ/тормоз/реверс + повороты (на скорости),
+ столкновения с миром, передние колёса доворачивают;
+
камера машины — следует за авто; V циклит follow / капот /
+ кинематографичный ракурс;
+
HUD водителя — спидометр (км/ч) и передача появляются сами;
+
onVehicleEnter / onVehicleExit — события для логики (квесты,
+ деньги за поездку).
+
+
+
Шаг 1. Заспавнить машину
+
+ Одна строка создаёт готовый к езде автомобиль. model —
+ одна из встроенных 3D-моделей (car-taxi / car-sedan / car-truck /
+ car-suv-luxury и др.), name показывается в подсказке F.
+
+
+ {`game.scene.spawn('vehicle:car', {
+ x: -28, y: 0.5, z: -22, rotationY: 0,
+ model: 'car-taxi', color: '#ffd23a', name: 'Natsune Alltima',
+ params: { maxSpeed: 14, turnSpeed: 1.8, enginePower: 16, brake: 28 },
+});`}
+
+ Сиденье водителя создаётся автоматически — отдельно регистрировать
+ «вход» не нужно. Подошёл к машине → подсказка «[F] Enter» с её именем.
+ Управление: W газ, S тормоз/назад, A/D руль,
+ Space ручник, V камера, E выйти.
+
+
+
Шаг 2. Реакция на посадку (квесты, деньги)
+
+ Глобальные события onVehicleEnter / onVehicleExit
+ дают зацепку для логики игры — например, показать инструкцию или
+ начать отсчёт заказа такси.
+
Шаг 3. Hold-to-action (защита от случайных нажатий)
+
+ Любое взаимодействие можно сделать «по удержанию» — игрок должен
+ подержать клавишу, а не случайно ткнуть. Полезно для важных действий
+ (подобрать клиента, продать машину). Опция holdDuration в
+ onInteract:
+
+ Тебе не нужно писать «низкоуровневую» возню — движок берёт её на себя:
+
+
+
Машина сама встаёт на землю. Где бы ты ни задал спавн,
+ авто опускается на пол/дорогу — не висит в воздухе и не тонет;
+
Звук мотора. Пока едешь — слышен живой рокот двигателя:
+ чем быстрее, тем плотнее и громче. На стоянке — тихо;
+
Водитель скрывается за рулём и появляется сбоку при выходе;
+
Падение в бездну = автоматический выход + респавн на старте.
+
+
+
Почему это важно
+
+ Машина — отдельная подсистема: пока ты за рулём, WASD управляют
+ автомобилем, а не ходьбой, и камера автоматически переключается
+ на «погоню за машиной». Выход возвращает обычное управление. На этой
+ базе строятся целые игры: такси, гонки, доставка, мафия-симулятор.
+
+
+
+ Заспавни вторую машину другой модели (model: 'car-suv-luxury')
+ с другим цветом и параметрами (быстрее: maxSpeed: 20).
+ Поставь рядом столб (game.scene.spawn('primitive:cylinder', ...))
+ и проверь — машина останавливается, столб стоит.
+
+ >
+ ),
+ },
+
};
/** Есть ли готовый текст урока для игры с таким id. */
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index 57a0a01..ce59924 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -41,6 +41,8 @@ import {
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
+import { VehicleManager } from './VehicleManager';
+import { VehicleHud } from './VehicleHud';
import { BlockManager } from './BlockManager';
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
@@ -154,6 +156,9 @@ export class BabylonScene {
// импортировал их напрямую (избегаем циклических импортов).
this.placementManager = null;
this.shopInventoryUi = null;
+ this.vehicleManager = null; // задача 14 — система транспорта
+ this.vehicleHud = null;
+ this._VehicleHudClass = VehicleHud;
this._PlacementManagerClass = PlacementManager;
this._ShopInventoryUiClass = ShopInventoryUi;
// Экран загрузки (задача 12) — DOM-оверлей + конфиг проекта.
@@ -1273,6 +1278,8 @@ export class BabylonScene {
// Voxel-террейн тоже участвует в физике. У террейна свой размер
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
+ // Задача 14 — система транспорта (нужны physics + modelManager).
+ this.vehicleManager = new VehicleManager(this);
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
// voxelWorld), что позволяет постепенно мигрировать без поломки.
@@ -1917,6 +1924,24 @@ export class BabylonScene {
if (typeof mesh.getBoundingInfo !== 'function') return;
if (typeof mesh.getTotalVertices !== 'function') return;
if (mesh.getTotalVertices() <= 0) return;
+ // ОПТИМИЗАЦИЯ ТЕНЕЙ: мелкие/тонкие меши (окна, пояски, разметка, детали
+ // мебели) НЕ кастят тень — их тень незаметна, но каждый caster дорого
+ // стоит в shadow-map (на сцене из сотен примитивов давало 5-15 FPS).
+ // Кастят тень только заметные объекты (дома, машины, деревья, столбы).
+ try {
+ const bb = mesh.getBoundingInfo().boundingBox;
+ const ext = bb.extendSizeWorld || bb.extendSize; // half-размеры
+ if (ext) {
+ const w = ext.x * 2, h = ext.y * 2, d = ext.z * 2;
+ const maxDim = Math.max(w, h, d);
+ const minDim = Math.min(w, h, d);
+ // мелкий объект (всё < 1.6) ИЛИ очень тонкая пластина (< 0.35)
+ if (maxDim < 1.6 || minDim < 0.35) return;
+ // огромный плоский пол/дорога (> 30 по горизонтали и плоский) —
+ // не нужен как caster (только принимает тень).
+ if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
+ }
+ } catch (e) { /* если не смогли измерить — добавляем как раньше */ }
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
}
@@ -7625,6 +7650,9 @@ export class BabylonScene {
this.gameRuntime = null;
}
+ // Задача 14: убрать машины + HUD водителя.
+ if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) { /* ignore */ } }
+ if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) { /* ignore */ } this.vehicleHud = null; }
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; }
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) { /* ignore */ } this.shopInventoryUi = null; }
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index a725c95..52fa64c 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -454,6 +454,12 @@ export class GameRuntime {
this._objectData = {};
this._interactables = [];
this._activeInteractRef = null;
+ // Задача 14: убрать машины и HUD водителя — без этого при повторном
+ // start (например двойной enterPlayMode) машины дублируются.
+ try { this.scene3d?.vehicleManager?.dispose?.(); } catch (e) {}
+ try { this.scene3d?.vehicleHud?.remove?.(); } catch (e) {}
+ this._vehHudShown = false;
+ try { if (this.scene3d?.player) this.scene3d.player._inVehicle = null; } catch (e) {}
this._watchedTouchRefs = null;
this._watchedClickRefs = null;
this._roomState = {};
@@ -587,6 +593,9 @@ export class GameRuntime {
// ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом
if (this._interactables.length > 0) this._updateInteractables();
+ // Задача 14: HUD водителя — спидометр + передача, пока игрок в машине.
+ this._updateVehicleHud();
+
// Детект смерти игрока — событие game.onPlayerDied (один раз на смерть)
const hp = this.scene3d?.player?.hp ?? 100;
const aliveNow = hp > 0;
@@ -755,8 +764,37 @@ export class GameRuntime {
}
}
+ /** Задача 14: HUD водителя — графический спидометр со стрелкой (SVG). */
+ _updateVehicleHud() {
+ const player = this.scene3d?.player;
+ const veh = player?._inVehicle;
+ if (veh) {
+ const hud = this._ensureVehicleHud();
+ if (hud) {
+ if (!this._vehHudShown) { try { hud.show(veh.params?.maxSpeed); } catch (e) {} this._vehHudShown = true; }
+ try { hud.update(veh.speed); } catch (e) {}
+ }
+ } else if (this._vehHudShown) {
+ this._vehHudShown = false;
+ try { this.scene3d?.vehicleHud?.remove(); } catch (e) {}
+ }
+ }
+
+ _ensureVehicleHud() {
+ if (this.scene3d?.vehicleHud) return this.scene3d.vehicleHud;
+ if (!this.scene3d || !this.scene3d._VehicleHudClass) return null;
+ try { this.scene3d.vehicleHud = new this.scene3d._VehicleHudClass(this.scene3d); }
+ catch (e) { this._log('error', 'vehicleHud init: ' + (e?.message || e)); }
+ return this.scene3d.vehicleHud || null;
+ }
+
/** Резолв позиции интерактивного объекта (по ref). */
_resolveInteractPos(it) {
+ // Задача 14: машина — позиция из VehicleManager.
+ if (it.ref && it.ref.indexOf('vehicle:') === 0) {
+ const veh = this.scene3d?.vehicleManager?.getById?.(Number(it.ref.slice(8)));
+ return veh ? { x: veh.pos.x, y: veh.pos.y, z: veh.pos.z } : null;
+ }
const tgt = this._resolveTweenTarget(it.ref);
if (tgt) {
const d = tgt.data;
@@ -774,8 +812,29 @@ export class GameRuntime {
if (!this._activeInteractRef) return;
const it = this._interactables.find(x => x.ref === this._activeInteractRef);
if (!it || it.key !== String(key).toLowerCase()) return;
- // событие 'interact' скрипту с target = этим объектом
- this.routeEvent(it.target, 'interact', {});
+ this._fireInteract(it);
+ }
+
+ /** Сработавший интеракт (мгновенный или по завершению hold). */
+ _fireInteract(it) {
+ if (!it) return;
+ if (it.isInst) {
+ // findOne(ref).onInteract → instInteract в worker.
+ for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref });
+ } else if (it.target) {
+ this.routeEvent(it.target, 'interact', {});
+ }
+ // Задача 14: вход в машину — если ref это vehicle и игрок не за рулём.
+ if (it.ref && it.ref.indexOf('vehicle:') === 0) {
+ const vid = Number(it.ref.slice(8));
+ const veh = this.scene3d?.vehicleManager?.getById?.(vid);
+ const player = this.scene3d?.player;
+ if (veh && player && !player._inVehicle) {
+ player.enterVehicle(veh);
+ player._onVehicleExit = (v) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleExit', vehicleId: v?.id }); };
+ for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'vehicleEnter', vehicleId: vid });
+ }
+ }
}
/** Прокрутка всех активных твинов на dt секунд. */
@@ -2935,6 +2994,8 @@ export class GameRuntime {
text: payload.text || 'Взаимодействовать',
distance: Number(payload.distance) || 4,
key: payload.key || 'e',
+ holdDuration: Number(payload.holdDuration) || 0,
+ isInst: false,
});
}
} catch (e) {
@@ -2942,6 +3003,23 @@ export class GameRuntime {
}
return;
}
+ if (cmd === 'inst.registerInteract') {
+ // Задача 14: findOne(ref).onInteract — интеракт по ref (не target-скрипт).
+ try {
+ const ref = payload?.ref;
+ if (ref && !this._interactables.some(it => it.ref === ref)) {
+ this._interactables.push({
+ ref, target: null,
+ text: payload.text || 'Взаимодействовать',
+ distance: Number(payload.distance) || 4,
+ key: payload.key || 'e',
+ holdDuration: Number(payload.holdDuration) || 0,
+ isInst: true,
+ });
+ }
+ } catch (e) { /* ignore */ }
+ return;
+ }
if (cmd === 'scene.setLabel') {
try {
const ref = payload?.ref;
@@ -3779,6 +3857,33 @@ export class GameRuntime {
}
this.scheduleSceneSnapshot();
}
+ } else if (kind === 'vehicle') {
+ // Задача 14: машина. subType игнорируем, модель в payload.model.
+ const opts = payload;
+ const p = this.scene3d?.vehicleManager?.spawn({
+ model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
+ params: opts.params, x: opts.x, y: opts.y, z: opts.z,
+ rotationY: opts.rotationY || 0, ref,
+ });
+ Promise.resolve(p).then((vid) => {
+ if (vid == null) return;
+ const realRef = 'vehicle:' + vid;
+ this._localToReal.set(ref, realRef);
+ this._notifySpawnResolved(ref, realRef);
+ // Авто-регистрация сиденья водителя как интерактивной зоны (F → сесть).
+ const veh = this.scene3d?.vehicleManager?.getById?.(vid);
+ if (veh && !this._interactables.some(it => it.ref === realRef)) {
+ this._interactables.push({
+ ref: realRef, target: null,
+ text: 'Enter', objectName: veh.name,
+ distance: Math.max(4, veh.half.d + 2), key: 'f',
+ holdDuration: 0.4, isInst: true, isVehicle: true,
+ });
+ }
+ this.scheduleSceneSnapshot();
+ }).catch((err) => {
+ this._log('error', 'spawn vehicle failed: ' + (err?.message || err));
+ });
}
} catch (e) {
this._log('error', 'scene.spawn failed: ' + (e?.message || e));
diff --git a/src/editor/engine/ModelManager.js b/src/editor/engine/ModelManager.js
index 0c14924..d457be9 100644
--- a/src/editor/engine/ModelManager.js
+++ b/src/editor/engine/ModelManager.js
@@ -294,8 +294,12 @@ export class ModelManager {
r.getChildMeshes(false).forEach(m => {
m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
- // Тени: GLB-модель принимает тени от мира.
- m.receiveShadows = true;
+ // Тени: GLB-модель принимает тени от мира. На InstancedMesh
+ // receiveShadows не действует (Babylon-warning + лишняя работа) —
+ // ставим только на обычных мешах.
+ if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
+ m.receiveShadows = true;
+ }
clonedMeshes.push(m);
});
// И сам root тоже на всякий
diff --git a/src/editor/engine/PlayerController.js b/src/editor/engine/PlayerController.js
index 03c29ab..d94238a 100644
--- a/src/editor/engine/PlayerController.js
+++ b/src/editor/engine/PlayerController.js
@@ -1,2872 +1,3073 @@
-/**
- * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения).
- *
- * Камера:
- * 1st person — камера в позиции глаз игрока, модель невидима.
- * 3rd person — камера сзади-сверху, модель видна.
- * Переключение: клавиша C циклит first ↔ third.
- * Колесо мыши в third-person — меняет дистанцию (zoom).
- *
- * Модель игрока:
- * Грузим GLB через ModelManager (тот же что для editor-моделей).
- * Корневой TransformNode хранит position/rotation модели.
- * В 3rd person модель видна и крутится в направлении движения.
- * Анимации (idle/walk/sprint) переключаются по скорости/спринту.
- *
- * Управление:
- * W/A/S/D / стрелки — движение в горизонтальной плоскости
- * Space — прыжок
- * Shift — спринт (×1.7)
- * C — переключить камеру 1st ↔ 3rd
- * Esc — выйти из игры (через pointer-lock release)
- */
-import {
- Vector3, UniversalCamera, SceneLoader, TransformNode,
- MeshBuilder, StandardMaterial, Color3,
- Quaternion, Space, Ray,
-} from '@babylonjs/core';
-import { getModelType } from './ModelTypes';
-import { R15Skeleton } from './R15Skeleton';
-import { R15Animator } from './R15Animator';
-
-// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
-// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
-const CAMERA_MODES = ['third', 'first', 'front'];
-// Для режима 'sideview' (Кубикон Dash):
-// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
-// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб
-// и ~12м препятствий впереди влезали в кадр на 16:9.
-const SIDEVIEW_DIST = 14;
-const SIDEVIEW_HEIGHT = 2.5;
-
-export class PlayerController {
- constructor(scene, canvas, physics, scene3d = null) {
- this.scene = scene;
- this.canvas = canvas;
- this.physics = physics;
- this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint)
- this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали
-
- // AABB
- this.HALF_W = 0.3;
- this.HALF_H = 0.9;
- this.HALF_D = 0.3;
- this.EYE_HEIGHT = 0.7; // глаза от центра AABB
-
- this.WALK_SPEED = 4.5;
- this.SPRINT_MULT = 1.7;
- this.JUMP_VELOCITY = 8;
- this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне)
- this._speedMul = 1; // множитель скорости передвижения
- this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная)
- this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт)
- this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе
- this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
- this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
- this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
- // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
- // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
- this._autoRunSpeed = 0;
- // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе).
- // В sideview-камере при прыжке куб эффектно крутится — визитка GD.
- this._dashSpinAngle = 0;
- // Camera shake: amplitude + remaining time. Применяется в _tick после
- // _computeCameraPos. Используется через game.camera.shake(amp, dur).
- this._cameraShakeAmp = 0;
- this._cameraShakeLeft = 0;
- // Управление камерой из скрипта (Фаза 5.7). null = обычная камера
- // игрока. Иначе объект режима:
- // { mode:'focus', getTarget } — следить за объектом;
- // { mode:'cutscene', points, durations, ... } — пролёт по точкам.
- this._cameraOverride = null;
- // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть.
- // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround.
- this._coyoteLeft = 0;
- this._doubleJumpEnabled = false;
- this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте»
- // Кубикон Dash: направление гравитации. +1 = нормально (вниз),
- // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD).
- // Применяется только в sideview-режиме. Влияет на:
- // - vy += GRAVITY * gravityDir * dt
- // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз)
- // - "onGround" определение: hitY + vy*gravityDir < 0
- // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте).
- this._gravityDir = 1;
- // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка,
- // 1 = полностью скользко (инерция держится бесконечно). Реалистичный
- // лёд = ~0.85. Настраивается через game.player.setIceFriction(value).
- this._iceFriction = 0;
- this._iceVelX = 0;
- this._iceVelZ = 0;
- // Присед — уменьшает высоту AABB. Включается через
- // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45.
- this._crouching = false;
- this.HALF_H_NORMAL = 0.9;
- this.HALF_H_CROUCH = 0.45;
- this.GRAVITY = -22;
- this.MOUSE_SENSITIVITY = 0.0025;
-
- // 3rd person camera (Roblox-style: 0.5 .. 32)
- this.THIRD_DISTANCE_MIN = 0.5;
- this.THIRD_DISTANCE_MAX = 32;
- this.THIRD_DISTANCE_DEFAULT = 5;
- this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока
- // Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5)
- this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
- // Lockfirst-режим: нельзя выйти из first-person зумом наружу
- this._lockFirstPerson = false;
- // Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере
- // (включается клавишей L по дефолту, или game.player.setShiftLock(true))
- this._shiftLock = false;
- // Видимость курсора по умолчанию (game.input.setMouseIconVisible)
- this._mouseIconVisible = true;
- // Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован)
- // / 'lockcurrent' (зафиксирован на текущей позиции)
- this._mouseBehavior = 'default';
- // Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third)
- this._rmbHeld = false;
-
- this.camera = null;
- this._active = false;
- this._onExitRequest = null;
-
- // Состояние игрока
- this._pos = new Vector3(0, 5, 0);
- this._vy = 0;
- this._yaw = 0;
- this._pitch = 0;
-
- // Камера. Дефолт — первое лицо (как в большинстве игр).
- this._cameraMode = 'third';
- this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
-
- // Ввод
- this._codes = new Set();
- this._shift = false;
-
- // Auto-step visual smoothing. Когда PhysicsAABB телепортирует
- // игрока вверх на уступ (steppedUpBy), физически он уже наверху,
- // но мы интерполируем рендер: показываем модель и камеру со сдвигом
- // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0.
- // Получается визуально как плавный «полупрыжок» без рывка.
- this._stepUpVisualOffset = 0;
- // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс.
- this._stepUpDecay = 4.5;
-
- // Модель игрока (грузится в start)
- // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
- this._modelTypeId = 'skin_bacon-hair';
- this._modelRoot = null;
- this._modelMeshes = [];
- // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
- // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР
- // в _tick (после анимаций), чтобы скрин оставался скрытым даже после
- // асинхронной загрузки модели или после _applyCameraMode.
- this._skinVisibleScripted = true;
- this._animations = {};
- this._currentAnim = null;
- // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока).
- // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72.
- this._modelScale = 0.72;
-
- // === R15-скин (bacon-hair и др.) ===
- // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций).
- // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из
- // characters//body.glb, детектируем скелет, анимируем
- // процедурно через R15Animator (см. _loadPlayerModel / _tick).
- this._isR15 = false; // флаг: загружен валидный R15-скелет
- this._r15Skeleton = null; // R15Skeleton — резолвер костей
- this._r15Animator = null; // R15Animator — процедурные анимации
- this._skinManifest = null; // кеш skins_manifest.json
- this._skinOverrides = {}; // overrides текущего скина
-
- // === Жизни игрока ===
- this.maxHp = 100;
- this.hp = 100;
- this._lastDamageTime = 0;
- this._invulnerabilityTime = 0.5; // 500мс i-frames после удара
- this._onHpChange = null;
- this._onDeath = null;
-
- // Звук шагов — простой генератор через Web Audio API.
- // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE.
- this._audioCtx = null;
- this._distanceSinceLastStep = 0;
- this.STEP_DISTANCE_WALK = 1.6;
- this.STEP_DISTANCE_SPRINT = 1.1;
-
- // Угол поворота модели — следует за направлением движения, а не yaw камеры.
- // Когда игрок стоит — сохраняем последний угол.
- this._modelYaw = 0;
- this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с)
-
- this._listeners = [];
- this._beforeRender = null;
-
- // === Stick к движущейся платформе ===
- // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его
- // на дельту движения этого объекта (его позиция могла измениться).
- this._lastGroundData = null;
- this._lastGroundPos = null;
-
- // === Тач-режим (мобилки/планшеты) ===
- // Если true — pointer-lock не запрашивается, mouse-listener не активен,
- // ввод управляется снаружи через setVirtualKey/addCameraDelta.
- this._touchMode = false;
- // Фактическая скорость поворота камеры от тача (рад/пиксель).
- this.TOUCH_SENSITIVITY = 0.005;
- }
-
- /**
- * Включить тач-режим. Вызывать ДО start(), на тач-устройствах.
- * В этом режиме:
- * - pointer-lock НЕ запрашивается
- * - mousemove игнорируется
- * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры),
- * но дополнительно работают setVirtualKey() / addCameraDelta().
- */
- setTouchMode(enabled) {
- this._touchMode = !!enabled;
- }
-
- /**
- * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code:
- * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space'
- * Для шифта — отдельный параметр.
- */
- setVirtualKey(code, pressed) {
- if (pressed) this._codes.add(code);
- else this._codes.delete(code);
- }
-
- /** Программное нажатие/отпускание Shift (бег). */
- setVirtualShift(pressed) {
- this._shift = !!pressed;
- }
-
- /**
- * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены).
- * dx, dy — пиксели свайпа.
- */
- addCameraDelta(dx, dy) {
- this._yaw += dx * this.TOUCH_SENSITIVITY;
- this._pitch += dy * this.TOUCH_SENSITIVITY;
- const lim = Math.PI / 2 - 0.05;
- if (this._pitch > lim) this._pitch = lim;
- if (this._pitch < -lim) this._pitch = -lim;
- }
-
- /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */
- triggerJump() {
- this._codes.add('Space');
- // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить
- // нажатие и инициировать прыжок (он проверяет onGround + Space).
- setTimeout(() => this._codes.delete('Space'), 100);
- }
-
- /**
- * Аналоговый ввод движения для тач-джойстика.
- * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры),
- * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint).
- *
- * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться
- * к дискретным клавишам, передай null.
- */
- setAnalogMove(x, y) {
- if (x === null || y === null) {
- this._analogMove = null;
- return;
- }
- if (!this._analogMove) this._analogMove = { x: 0, y: 0 };
- this._analogMove.x = x;
- this._analogMove.y = y;
- }
-
- setOnExitRequest(cb) {
- this._onExitRequest = cb;
- }
-
- /** Установить тип модели персонажа — должен быть вызван ДО start(). */
- setModelType(typeId) {
- this._modelTypeId = typeId || 'character-a';
- }
-
- /**
- * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
- * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
- * грузит новую модель (R15 или non-humanoid). Возвращает Promise.
- *
- * Используется из game.player.setSkin(slug).
- */
- async reloadSkin(typeId) {
- if (!this._active) return false;
- const newType = typeId || 'character-a';
- if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
- // 1) Выгрузить текущую модель и связанные аниматоры.
- try {
- if (this._modelRoot) { this._modelRoot.dispose(false, true); }
- } catch (e) { /* ignore */ }
- this._modelRoot = null;
- this._modelMeshes = [];
- this._rightArmMeshes = [];
- this._r15Skeleton = null;
- this._r15Animator = null;
- this._isR15 = false;
- this._modelKind = 'r15';
- this._modelHipHeight = null;
- this._nonHumanoidBox = null;
- // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
- this.HALF_W = 0.3;
- this.HALF_H = 0.9;
- this.HALF_D = 0.3;
- this.HALF_H_NORMAL = 0.9;
- this.EYE_HEIGHT = 0.7;
- // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
- this._pos.y += 0.5;
- // 4) Загрузить новую модель.
- this._modelTypeId = newType;
- await this._loadPlayerModel();
- return !!this._modelRoot;
- }
-
- /**
- * Запустить режим игры.
- * spawnPos — точка спавна. Если не указано — (0, 5, 0).
- */
- async start(spawnPos = null) {
- if (this._active) return;
- this._active = true;
-
- if (spawnPos) {
- this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z);
- } else {
- this._pos = new Vector3(0, 5 + this.HALF_H, 0);
- }
- this._vy = 0;
- this._yaw = 0;
- this._pitch = 0;
- this._modelYaw = 0;
- this._codes.clear();
- this._shift = false;
-
- // FPS-камера
- const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene);
- cam.minZ = 0.1;
- cam.maxZ = 1000;
- cam.fov = 1.05;
- cam.inputs.clear();
- this.scene.activeCamera = cam;
- this.camera = cam;
-
- this._setupInput();
-
- // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит
- // без меша (или появляется частично), а движение/колайдер уже
- // активны. start() теперь async-функция — все её вызовы (`await`).
- await this._loadPlayerModel();
-
- // Render-loop hook
- this._beforeRender = () => this._tick();
- this.scene.registerBeforeRender(this._beforeRender);
-
- // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу:
- // - first / lockfirst — постоянный lock
- // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления
- // Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit).
- // ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'.
- const needLockAtStart = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (needLockAtStart) {
- this._requestPointerLockSafe();
- }
- // Применяем видимость курсора (по умолчанию виден в third).
- this._applyCursorVisibility();
- }
-
- /**
- * Установить курсор видимым/скрытым через CSS на canvas.
- * Pointer-lock сам прячет курсор когда активен, но в third без lock
- * мы можем скрыть курсор через `cursor:none` если разработчик
- * выключил его через setMouseIconVisible(false).
- */
- _applyCursorVisibility() {
- if (!this.canvas) return;
- const locked = (document.pointerLockElement === this.canvas);
- // Если lock активен — курсор и так скрыт. Иначе зависит от настроек.
- if (locked) return;
- const show = this._mouseIconVisible && !this._shiftLock;
- this.canvas.style.cursor = show ? '' : 'none';
- }
-
- /**
- * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
- * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём
- * pointerlockchange и пробуем снова один раз.
- */
- /**
- * Включить/выключить «UI-режим курсора».
- * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается.
- * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false).
- */
- /** Колбэк изменения HP — ({hp, maxHp}). */
- setOnHpChange(cb) { this._onHpChange = cb; }
- setOnDeath(cb) { this._onDeath = cb; }
-
- /** Нанести урон игроку (с учётом i-frames). */
- takeDamage(amount, source) {
- if (this.hp <= 0) return;
- const now = performance.now() / 1000;
- if (now - this._lastDamageTime < this._invulnerabilityTime) return;
- this._lastDamageTime = now;
- this.hp = Math.max(0, this.hp - Math.max(0, amount));
- // Flash-эффект для UI (через onHpChange флаг damaged=true)
- if (this._onHpChange) {
- try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {}
- }
- // Звук «ой»
- this._playHurtSound();
- if (this.hp === 0) {
- // Эффект распада
- this._spawnDeathDebris();
- // Прячем модель игрока
- if (this._modelRoot) this._modelRoot.setEnabled(false);
- if (this._onDeath) {
- try { this._onDeath(); } catch (e) {}
- }
- }
- }
-
- /** Распад на куски при смерти. */
- _spawnDeathDebris() {
- if (!this._pos) return;
- const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z;
- const colors = [
- new Color3(0.95, 0.78, 0.6), // кожа
- new Color3(0.7, 0.5, 0.4),
- new Color3(0.4, 0.4, 0.7), // одежда
- new Color3(0.3, 0.25, 0.2),
- ];
- for (let i = 0; i < 10; i++) {
- const size = 0.18 + Math.random() * 0.14;
- const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene);
- const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene);
- mat.diffuseColor = colors[i % colors.length];
- mat.specularColor = new Color3(0, 0, 0);
- cube.material = mat;
- cube.position.set(
- cx + (Math.random() - 0.5) * 0.5,
- cy + Math.random() * 0.6,
- cz + (Math.random() - 0.5) * 0.5
- );
- cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
- cube.isPickable = false;
- cube.alwaysSelectAsActiveMesh = true;
- const debris = {
- mesh: cube, mat,
- vx: (Math.random() - 0.5) * 5,
- vy: 4 + Math.random() * 3,
- vz: (Math.random() - 0.5) * 5,
- rx: (Math.random() - 0.5) * 10,
- ry: (Math.random() - 0.5) * 10,
- rz: (Math.random() - 0.5) * 10,
- age: 0,
- life: 2.0,
- };
- if (!this._debris) this._debris = [];
- this._debris.push(debris);
- }
- }
-
- /** Тик debris — вызывается в _tick. */
- _tickDebris(dt) {
- if (!this._debris || this._debris.length === 0) return;
- const G = -10;
- const next = [];
- for (const d of this._debris) {
- d.age += dt;
- if (d.age >= d.life) {
- try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
- continue;
- }
- d.vy += G * dt;
- d.mesh.position.x += d.vx * dt;
- d.mesh.position.y += d.vy * dt;
- d.mesh.position.z += d.vz * dt;
- if (d.mesh.position.y < 0.1) {
- d.mesh.position.y = 0.1;
- d.vy *= -0.4;
- d.vx *= 0.6;
- d.vz *= 0.6;
- }
- d.mesh.rotation.x += d.rx * dt;
- d.mesh.rotation.y += d.ry * dt;
- d.mesh.rotation.z += d.rz * dt;
- const fadeStart = d.life - 0.5;
- if (d.age > fadeStart) {
- const k = 1 - (d.age - fadeStart) / 0.5;
- d.mesh.visibility = Math.max(0, k);
- }
- next.push(d);
- }
- this._debris = next;
- }
-
- /** Короткий звук «ой» когда получили урон. */
- _playHurtSound() {
- try {
- if (!this._audioCtx) {
- const Ctx = window.AudioContext || window.webkitAudioContext;
- if (!Ctx) return;
- this._audioCtx = new Ctx();
- }
- const ctx = this._audioCtx;
- if (ctx.state === 'suspended') ctx.resume();
- const t = ctx.currentTime;
- const osc = ctx.createOscillator();
- osc.type = 'sawtooth';
- osc.frequency.setValueAtTime(220, t);
- osc.frequency.exponentialRampToValueAtTime(80, t + 0.15);
- const g = ctx.createGain();
- g.gain.setValueAtTime(0.15, t);
- g.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
- osc.connect(g).connect(ctx.destination);
- osc.start(t);
- osc.stop(t + 0.22);
- } catch (e) { /* ignore */ }
- }
-
- /** Полное восстановление HP (например при респавне). */
- healFull() {
- this.hp = this.maxHp;
- this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна
- // Возвращаем модель
- if (this._modelRoot) this._modelRoot.setEnabled(true);
- // Сбрасываем оставшиеся debris
- if (this._debris) {
- for (const d of this._debris) {
- try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
- }
- this._debris = [];
- }
- if (this._onHpChange) {
- try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {}
- }
- }
-
- setUiCursorMode(enabled) {
- this._uiCursorMode = !!enabled;
- if (enabled) {
- // Освобождаем мышь
- if (document.pointerLockElement === this.canvas) {
- try { document.exitPointerLock(); } catch (e) { /* ignore */ }
- }
- } else {
- // Возвращаем lock — но только если мы реально активны
- if (this._active) {
- this._requestPointerLockSafe();
- }
- }
- }
- isUiCursorMode() { return !!this._uiCursorMode; }
-
- /**
- * Callback, который вызывается при движении мыши в UI-режиме.
- * fn(x, y) — нормализованные координаты [0..1] относительно канваса.
- * Используется для drag-механик (Дальгона и т.д.).
- */
- setUiMouseMoveCallback(fn) {
- this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null;
- }
- /** mousedown в UI-режиме. fn(x, y). */
- setUiMouseDownCallback(fn) {
- this._uiMouseDownCb = (typeof fn === 'function') ? fn : null;
- }
- /** mouseup в UI-режиме. fn(x, y). */
- setUiMouseUpCallback(fn) {
- this._uiMouseUpCb = (typeof fn === 'function') ? fn : null;
- }
-
- _requestPointerLockSafe(retried = false) {
- if (!this._active || !this.canvas?.requestPointerLock) return;
- // На тач-устройствах pointer-lock не нужен — управление через touch-overlay
- if (this._touchMode) return;
- // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui'))
- // — не захватываем мышь.
- if (this._uiCursorMode) return;
- // Если уже есть lock на этот canvas — нечего делать
- if (document.pointerLockElement === this.canvas) return;
- // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем
- if (document.pointerLockElement && document.pointerLockElement !== this.canvas) {
- if (retried) return; // только одна попытка повтора
- const onChange = () => {
- document.removeEventListener('pointerlockchange', onChange);
- if (this._active) this._requestPointerLockSafe(true);
- };
- document.addEventListener('pointerlockchange', onChange, { once: true });
- return;
- }
- requestAnimationFrame(() => {
- if (!this._active) return;
- try {
- const p = this.canvas.requestPointerLock();
- // Promise-форма: ловим reject (SecurityError) и пробуем повтор
- if (p && typeof p.catch === 'function') {
- p.catch((err) => {
- if (!this._active) return;
- // SecurityError — попробуем ещё раз через кадр (один раз)
- if (!retried && err && err.name === 'SecurityError') {
- setTimeout(() => this._requestPointerLockSafe(true), 50);
- }
- });
- }
- } catch (e) { /* legacy form, ignore */ }
- });
- }
-
- /**
- * Загрузить манифест R15-скинов (характеристики + overrides).
- * Кешируется в this._skinManifest. Возвращает массив skins или [].
- */
- async _loadSkinManifest() {
- if (this._skinManifest) return this._skinManifest;
- try {
- const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
- const json = await resp.json();
- this._skinManifest = json.skins || [];
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[PlayerController] skins_manifest load failed:', e);
- this._skinManifest = [];
- }
- return this._skinManifest;
- }
-
- /**
- * Определить путь к GLB и overrides для текущего _modelTypeId.
- * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста
- * - иначе → старая Kenney-модель через getModelType()
- * Возвращает { file, isR15, overrides } или null.
- */
- async _resolveModelSource() {
- const typeId = this._modelTypeId || 'character-a';
- if (typeId.startsWith('skin_')) {
- const manifest = await this._loadSkinManifest();
- const entry = manifest.find((s) => s.id === typeId);
- if (entry) {
- // kind определяет систему анимации:
- // 'r15' → R15-скелет (как раньше)
- // 'non-humanoid-mesh' → single-mesh, процедурное покачивание
- // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
- // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
- const kind = entry.kind || 'r15';
- return {
- file: '/kubikon-assets/' + entry.file,
- isR15: kind === 'r15',
- kind,
- overrides: entry.overrides || {},
- scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
- hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
- rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
- };
- }
- // нет в манифесте — пробуем прямой путь (старые R15-скины)
- return {
- file: `/kubikon-assets/characters/${typeId}/body.glb`,
- isR15: true,
- kind: 'r15',
- overrides: {},
- };
- }
- // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные
- // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
- if (typeId.startsWith('customskin:')) {
- const slug = typeId.slice('customskin:'.length);
- const list = this._scene3d?._skinsConfig?.customGlbs || [];
- const meta = list.find(g => g && g.slug === slug) || null;
- const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
- if (url) {
- return {
- file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
- scaleManifest: meta?.scale ?? 1.5,
- hipHeight: meta?.hipHeight ?? 0.4,
- rotationYOffset: meta?.rotationYOffset ?? 0,
- isDataUrl: true,
- };
- }
- return null;
- }
- const modelType = getModelType(typeId);
- if (!modelType) return null;
- return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
- }
-
- /** Загрузить GLB-модель персонажа и его анимации. */
- async _loadPlayerModel() {
- const source = await this._resolveModelSource();
- if (!source) return;
- if (!this._active) return;
-
- // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
- // ModelManager. Если бы мы использовали тот же AssetContainer
- // что и зомби (через _loadPrototype), повторный
- // instantiateModelsToScene давал меши с битыми материалами.
- // Babylon HTTP-кэш всё равно убирает сетевые запросы.
- let rootUrl, filename;
- if (source.isDataUrl) {
- // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
- // и filename=data:... с подсказкой расширения через ?name=.
- rootUrl = '';
- filename = source.file;
- } else {
- const lastSlash = source.file.lastIndexOf('/');
- rootUrl = source.file.substring(0, lastSlash + 1);
- filename = source.file.substring(lastSlash + 1);
- }
- let container;
- try {
- container = await SceneLoader.LoadAssetContainerAsync(
- rootUrl, filename, this.scene,
- null, source.isDataUrl ? '.glb' : undefined
- );
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error('[PlayerController] failed to load model:', e);
- return;
- }
- try {
- if (!this._active) {
- try { container.dispose(); } catch (e) {}
- return;
- }
- // Создаём корневой узел и инстанцируем модель туда
- const root = new TransformNode('playerModel', this.scene);
- // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока).
- // - R15-скины ('skin_*'): фиксированный 0.301 — модели
- // нормализованы к 5.98 ед пайплайном auto_rig_bacon
- // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах
- // с торчащими волосами/плащами (как у bacon-hair).
- // - Kenney-модели: старый 0.72.
- // - overrides.scale_mult — per-skin множитель из манифеста.
- const isNonHumanoid = source.kind === 'non-humanoid-mesh'
- || source.kind === 'non-humanoid-rigged';
- let modelScale;
- if (isNonHumanoid) {
- // Non-humanoid: базовый размер берём из манифеста (scale), а если
- // нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок).
- modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
- } else {
- modelScale = source.isR15 ? 0.301 : this._modelScale;
- const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
- modelScale *= scaleMult;
- }
- root.scaling = new Vector3(modelScale, modelScale, modelScale);
- if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
- const inst = container.instantiateModelsToScene(
- (name) => `player_${name}`,
- /*cloneAnimations*/ true,
- { doNotInstantiate: false }
- );
- for (const r of inst.rootNodes) {
- r.parent = root;
- }
- this._modelRoot = root;
- this._modelKind = source.kind || 'r15';
- // hipHeight: на сколько центр модели поднят от «низа ног».
- // Используется и для позиционирования модели, и для камеры/AABB.
- this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
-
- // Non-humanoid: нормализуем размер и опускаем модель на «ноги».
- if (isNonHumanoid) {
- this._setupNonHumanoidModel(root, modelScale, source);
- }
-
- // === R15-скин: детекция скелета ===
- // R15-скины приходят с встроенным скелетом Mixamo. Babylon
- // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер
- // и, если скелет валидный, помечаем _isR15 + создаём аниматор.
- this._isR15 = false;
- this._r15Skeleton = null;
- this._r15Animator = null;
- this._skinOverrides = source.overrides || {};
- // eslint-disable-next-line no-console
- console.log('[PlayerController] _loadPlayerModel: file=' + source.file
- + ' isR15=' + source.isR15
- + ' inst.skeletons=' + ((inst.skeletons || []).length)
- + ' rootNodes=' + (inst.rootNodes || []).length);
- if (source.isR15) {
- // Скелет ищем в нескольких местах: inst.skeletons (норма),
- // container.skeletons (иногда не клонируется), на мешах
- // модели (skeleton-property). Берём первый найденный.
- let sk = (inst.skeletons && inst.skeletons[0]) || null;
- if (!sk && container.skeletons && container.skeletons.length > 0) {
- sk = container.skeletons[0];
- }
- if (!sk) {
- const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
- if (meshWithSkel) sk = meshWithSkel.skeleton;
- }
- if (sk) {
- // eslint-disable-next-line no-console
- console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length
- + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(','));
- const r15 = new R15Skeleton(sk);
- if (r15.isValidR15()) {
- this._r15Skeleton = r15;
- this._isR15 = true;
- this._r15Animator = new R15Animator(r15, this._skinOverrides);
- // eslint-disable-next-line no-console
- console.log('[PlayerController] R15-скин загружен:',
- this._modelTypeId, '— костей:', r15.resolvedNames().length,
- 'overrides:', JSON.stringify(this._skinOverrides));
- } else {
- // eslint-disable-next-line no-console
- console.warn('[PlayerController] R15-скин', this._modelTypeId,
- '— скелет не прошёл валидацию. Зарезолвлено:',
- r15.resolvedNames().join(','),
- '| все кости скелета:',
- (sk.bones || []).map(b => b.name).join(','));
- }
- } else {
- // eslint-disable-next-line no-console
- console.warn('[PlayerController] R15-скин', this._modelTypeId,
- '— нет скелета в glb');
- }
- }
-
- // Собираем все mesh-чилдрены (для toggle visibility в 1st person)
- this._modelMeshes = root.getChildMeshes(false);
- // Игрок не должен ловить свой raycast → отключаем pickable
- for (const m of this._modelMeshes) {
- m.isPickable = false;
- if (m.alwaysSelectAsActiveMesh !== undefined) {
- m.alwaysSelectAsActiveMesh = true;
- }
- // Тени: персонаж принимает тени от мира и сам отбрасывает.
- m.receiveShadows = true;
- }
- try {
- if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') {
- for (const m of this._modelMeshes) {
- this._scene3d.addShadowCaster(m);
- }
- }
- } catch (e) { /* ignore */ }
- // У Kenney character имена `arm-left`/`arm-right` соответствуют
- // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right).
- // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его
- // правая рука у нас слева на экране.
- //
- // Берём именно arm-right (его правую) — это та рука куда логично
- // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4.
- this._rightArmMeshes = [];
- for (const m of this._modelMeshes) {
- const n = (m.name || '').toLowerCase();
- // Берём именно «right» — настоящая правая рука персонажа
- if (n === 'player_arm-right' || n.endsWith('arm-right')
- || n.includes('right-arm') || n.includes('rightarm')
- || n.includes('right-hand') || n.includes('hand-right')) {
- this._rightArmMeshes.push(m);
- break;
- }
- }
- // Fallback: если по имени не нашли — берём левую по позиции (x<0)
- if (this._rightArmMeshes.length === 0) {
- let bestMesh = null;
- let bestX = Infinity;
- for (const m of this._modelMeshes) {
- if (!m.position) continue;
- const n = (m.name || '').toLowerCase();
- if (n.includes('leg') || n.includes('foot') || n.includes('head')
- || n.includes('hat') || n.includes('torso') || n.includes('root')) continue;
- const px = m.position.x;
- const py = m.position.y;
- if (py < 0.8 || py > 2.2) continue;
- if (px >= -0.05) continue; // ищем X<0
- if (px < bestX) { bestX = px; bestMesh = m; }
- }
- if (bestMesh) this._rightArmMeshes = [bestMesh];
- }
- const arm = this._rightArmMeshes[0];
- if (arm) {
- this._rightArmX = arm.position.x;
- this._rightArmY = arm.position.y;
- this._rightArmZ = arm.position.z;
- }
-
- // Анимации.
- // R15-скины не содержат AnimationGroups (анимируются процедурно
- // через R15Animator в _tick). Kenney-модели — наоборот, имеют
- // встроенные AnimationGroups (idle/walk/sprint/jump).
- this._animations = {};
- if (!this._isR15) {
- const groups = inst.animationGroups || [];
- for (const g of groups) {
- const name = (g.name || '').toLowerCase();
- if (name.includes('idle')) this._animations.idle = g;
- else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g;
- else if (name.includes('walk')) this._animations.walk = g;
- else if (name.includes('jump')) this._animations.jump = g;
- g.stop();
- }
- this._playAnim('idle');
- }
- // Применяем текущий camera-mode (показать/скрыть модель)
- this._applyCameraMode();
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error('[PlayerController] failed to load model:', e);
- }
- }
-
- /**
- * Настройка non-humanoid модели (животное/машина/еда): нормализация
- * размера и опускание на «низ ног». В отличие от R15 (нормализованы
- * пайплайном), эти модели произвольного размера, поэтому считаем bbox.
- *
- * Локальные координаты root: модель должна стоять так, чтобы её низ был
- * на y=0 (там «ноги»). PlayerController позиционирует root в точке
- * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
- */
- _setupNonHumanoidModel(root, scaleApplied, source) {
- try {
- // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
- // применения scaling root'а. Babylon refreshBoundingInfo нужен после
- // инстансинга.
- const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
- if (!meshes.length) return;
- root.computeWorldMatrix(true);
- let minY = Infinity, maxY = -Infinity, maxDim = 0;
- let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
- for (const m of meshes) {
- m.computeWorldMatrix(true);
- // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
- // скелета/морфов; без него minimumWorld у инстансов часто нулевой
- // или из исходной позы → центр считался неверно (баг пришельца/робота).
- try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
- const bi = m.getBoundingInfo();
- const bb = bi.boundingBox;
- const lo = bb.minimumWorld, hi = bb.maximumWorld;
- if (!lo || !hi) continue;
- minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
- minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
- minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
- }
- if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
- const h = maxY - minY;
- const w = maxX - minX;
- const d = maxZ - minZ;
- maxDim = Math.max(h, w, d);
- // === Центрирование модели через pivot-node ===
- // Многие Kenney-модели имеют origin НЕ в геометрическом центре
- // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
- // origin (баг пришельца/робота). Ручной сдвиг детей с делением на
- // scaleApplied неверен если у детей свой scale/rotation. Надёжно:
- // вставляем промежуточный pivot между root и моделью и смещаем pivot
- // на -localCenter (через инверсию world-матрицы root — точно при
- // любом scale/rotation).
- const worldCenter = new Vector3(
- (minX + maxX) / 2, // центр X
- minY, // низ Y (модель «садится» на ноги)
- (minZ + maxZ) / 2 // центр Z
- );
- // world-центр → локальные координаты root
- const invRoot = root.getWorldMatrix().clone().invert();
- const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
- const pivot = new TransformNode('playerModelPivot', this.scene);
- pivot.parent = root;
- pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
- // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
- for (const ch of root.getChildren().slice()) {
- if (ch === pivot) continue;
- ch.parent = pivot;
- }
- // Сохраняем размеры для настраиваемого AABB и камеры.
- // hipHeight из манифеста — приоритетно; иначе берём низ модели.
- this._nonHumanoidBox = { w, h, d };
- this._modelBaseHeight = h;
- // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
- // Ограничиваем разумными пределами чтобы не проваливаться/застревать.
- this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
- this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
- const halfH = Math.max(0.3, Math.min(1.0, h / 2));
- this.HALF_H = halfH;
- this.HALF_H_NORMAL = halfH;
- this.EYE_HEIGHT = halfH * 0.7;
- // eslint-disable-next-line no-console
- console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
- 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
- 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
- }
- }
-
- /**
- * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
- * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
- * беге + наклон в воздухе. Вызывается каждый кадр из _tick.
- * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
- */
- _animateNonHumanoidMesh(dt) {
- const root = this._modelRoot;
- if (!root) return;
- const t = (typeof performance !== 'undefined' && performance.now)
- ? performance.now() / 1000 : Date.now() / 1000;
- const speed = this._lastFrameSpeed || 0;
- // Базовое вращение по yaw уже выставляет _tick (он крутит модель под
- // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
- // поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
- let bobY = 0, tiltX = 0;
- if (!this._isGrounded) {
- tiltX = 0.2; // в воздухе — нос вверх
- } else if (speed > 0.1) {
- const bobFreq = 8 * Math.min(2, speed / 4);
- bobY = Math.sin(t * bobFreq) * 0.06;
- tiltX = Math.min(speed * 0.04, 0.13);
- } else {
- bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
- }
- // Применяем поверх позиции, которую _tick уже выставил в root.position.y.
- root.position.y += bobY;
- // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
- root.rotation.x = tiltX;
- }
-
- /** AABB игрока пересекает хотя бы один блок-воду. */
- _isInWater() {
- const bm = this._scene3d?.blockManager;
- if (!bm) return false;
- // FAST PATH: если на сцене нет водных блоков — точно не в воде.
- // Большинство карт (зомби-остров, любые «суховые») — без воды,
- // и тройной цикл ниже бесполезно тратит время каждый кадр.
- if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
- const px = this._pos.x, py = this._pos.y, pz = this._pos.z;
- const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D;
- // Проверяем клетки которые AABB перекрывает
- const gxMin = Math.floor(px - hw + 0.5);
- const gxMax = Math.floor(px + hw + 0.5);
- const gzMin = Math.floor(pz - hd + 0.5);
- const gzMax = Math.floor(pz + hd + 0.5);
- const gyMin = Math.floor(py - hh);
- const gyMax = Math.floor(py + hh);
- for (let gx = gxMin; gx <= gxMax; gx++) {
- for (let gy = gyMin; gy <= gyMax; gy++) {
- for (let gz = gzMin; gz <= gzMax; gz++) {
- const m = bm.blocks.get(`${gx},${gy},${gz}`);
- if (m?.metadata?.isWater) return true;
- }
- }
- }
- return false;
- }
-
- /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */
- _isSubmerged() {
- const bm = this._scene3d?.blockManager;
- if (!bm) return false;
- // FAST PATH: нет воды на сцене — не утопаем.
- if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
- // Проверяем «голову» — точку чуть ниже верха AABB
- const headY = this._pos.y + this.HALF_H - 0.1;
- const gx = Math.round(this._pos.x);
- const gy = Math.floor(headY);
- const gz = Math.round(this._pos.z);
- const m = bm.blocks.get(`${gx},${gy},${gz}`);
- return !!m?.metadata?.isWater;
- }
-
- /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */
- _playFootstep() {
- try {
- if (!this._audioCtx) {
- const Ctx = window.AudioContext || window.webkitAudioContext;
- if (!Ctx) return;
- this._audioCtx = new Ctx();
- }
- const ctx = this._audioCtx;
- if (ctx.state === 'suspended') ctx.resume();
-
- const now = ctx.currentTime;
- const duration = 0.08;
-
- // Источник — короткий шумовой буфер
- const sampleRate = ctx.sampleRate;
- const length = Math.floor(sampleRate * duration);
- const buffer = ctx.createBuffer(1, length, sampleRate);
- const data = buffer.getChannelData(0);
- for (let i = 0; i < length; i++) {
- data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум
- }
- const src = ctx.createBufferSource();
- src.buffer = buffer;
-
- // Lowpass для тяжёлого «тук» вместо высокого «шшш»
- const lowpass = ctx.createBiquadFilter();
- lowpass.type = 'lowpass';
- lowpass.frequency.value = 350;
- lowpass.Q.value = 1.5;
-
- // Envelope (быстрая атака, быстрое затухание)
- const gain = ctx.createGain();
- gain.gain.setValueAtTime(0, now);
- gain.gain.linearRampToValueAtTime(0.7, now + 0.005);
- gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
-
- src.connect(lowpass).connect(gain).connect(ctx.destination);
- src.start(now);
- src.stop(now + duration);
- } catch (e) {
- // ignore — звук не критичен
- }
- }
-
- /**
- * Звук прыжка — мягкий «boing» из двух слоёв:
- * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами»
- * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма»
- * Гораздо приятнее старого квадратного восходящего тона.
- */
- /**
- * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation.
- * Работает только для R15-скинов (Kenney-модели эмоций не имеют).
- */
- playEmote(name) {
- if (this._isR15 && this._r15Animator) {
- return this._r15Animator.playEmote(name);
- }
- return false;
- }
-
- /** Прервать текущую эмоцию персонажа. */
- stopEmote() {
- if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote();
- }
-
- _playJumpSound() {
- // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок
- // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся.
- if (typeof this._onJump === 'function') {
- try { this._onJump(); } catch (e) { /* ignore */ }
- }
- try {
- if (!this._audioCtx) {
- const Ctx = window.AudioContext || window.webkitAudioContext;
- if (!Ctx) return;
- this._audioCtx = new Ctx();
- }
- const ctx = this._audioCtx;
- if (ctx.state === 'suspended') ctx.resume();
- const now = ctx.currentTime;
- const out = ctx.destination;
-
- // Слой 1: низкий thump
- const thumpDur = 0.07;
- const thumpOsc = ctx.createOscillator();
- thumpOsc.type = 'sine';
- thumpOsc.frequency.setValueAtTime(110, now);
- thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur);
- const thumpGain = ctx.createGain();
- thumpGain.gain.setValueAtTime(0, now);
- thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005);
- thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur);
- thumpOsc.connect(thumpGain).connect(out);
- thumpOsc.start(now);
- thumpOsc.stop(now + thumpDur + 0.02);
-
- // Слой 2: «boing» — pitch-down sine
- const boingDur = 0.18;
- const boingOsc = ctx.createOscillator();
- boingOsc.type = 'sine';
- boingOsc.frequency.setValueAtTime(720, now + 0.005);
- boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur);
- const boingGain = ctx.createGain();
- boingGain.gain.setValueAtTime(0, now + 0.005);
- boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02);
- boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur);
- // Лёгкое vibrato чтобы было «живее»
- const lfo = ctx.createOscillator();
- lfo.type = 'sine';
- lfo.frequency.value = 14;
- const lfoGain = ctx.createGain();
- lfoGain.gain.value = 18; // ±18 Hz
- lfo.connect(lfoGain).connect(boingOsc.frequency);
- boingOsc.connect(boingGain).connect(out);
- boingOsc.start(now + 0.005);
- lfo.start(now + 0.005);
- boingOsc.stop(now + 0.005 + boingDur + 0.02);
- lfo.stop(now + 0.005 + boingDur + 0.02);
- } catch (e) { /* ignore */ }
- }
-
- /** Звук «бульк» при входе/выходе из воды. */
- _playSplashSound() {
- try {
- if (!this._audioCtx) {
- const Ctx = window.AudioContext || window.webkitAudioContext;
- if (!Ctx) return;
- this._audioCtx = new Ctx();
- }
- const ctx = this._audioCtx;
- if (ctx.state === 'suspended') ctx.resume();
- const now = ctx.currentTime;
- const duration = 0.35;
-
- // Шум с быстро падающим highpass — звук «всплеска»
- const length = Math.floor(ctx.sampleRate * duration);
- const buf = ctx.createBuffer(1, length, ctx.sampleRate);
- const data = buf.getChannelData(0);
- for (let i = 0; i < length; i++) {
- data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2;
- }
- const src = ctx.createBufferSource();
- src.buffer = buf;
-
- const bp = ctx.createBiquadFilter();
- bp.type = 'bandpass';
- bp.frequency.setValueAtTime(2000, now);
- bp.frequency.exponentialRampToValueAtTime(400, now + duration);
- bp.Q.value = 1.5;
-
- const g = ctx.createGain();
- g.gain.setValueAtTime(0, now);
- g.gain.linearRampToValueAtTime(0.6, now + 0.01);
- g.gain.exponentialRampToValueAtTime(0.001, now + duration);
-
- src.connect(bp).connect(g).connect(ctx.destination);
- src.start(now);
- src.stop(now + duration);
- } catch (e) { /* ignore */ }
- }
-
- _playAnim(name) {
- if (this._currentAnim === name) return;
- const target = this._animations[name];
- if (!target) {
- // если нужной нет — пробуем idle как fallback
- if (name !== 'idle' && this._animations.idle) {
- return this._playAnim('idle');
- }
- return;
- }
- // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ ===
- // Помогает дебажить вибрацию ног на склонах: если в логе видно
- // частое мерцание walk↔jump или idle↔walk — значит onGround или
- // isMoving скачет каждые несколько кадров.
- const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false };
- console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} `
- + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`);
- // Стопим текущую
- if (this._currentAnim && this._animations[this._currentAnim]) {
- this._animations[this._currentAnim].stop();
- }
- target.start(/*loop*/ true, /*speed*/ 1);
- this._currentAnim = name;
- }
-
- /**
- * Остановить режим игры. Освобождает все ресурсы.
- */
- stop() {
- if (!this._active) return;
- this._active = false;
-
- if (this._beforeRender) {
- this.scene.unregisterBeforeRender(this._beforeRender);
- this._beforeRender = null;
- }
- for (const { target, type, fn, opts } of this._listeners) {
- target.removeEventListener(type, fn, opts);
- }
- this._listeners = [];
-
- if (document.pointerLockElement === this.canvas) {
- document.exitPointerLock();
- }
-
- // Останавливаем все анимации
- for (const g of Object.values(this._animations)) {
- try { g.stop(); } catch (e) { /* ignore */ }
- }
- this._animations = {};
- this._currentAnim = null;
-
- // Удаляем якорь оружия
- if (this._weaponAnchor) {
- try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ }
- this._weaponAnchor = null;
- }
- // Сбрасываем все override'ы вращения
- if (this._meshRotationOverrides) {
- this._meshRotationOverrides.clear();
- }
- // Сброс R15-состояния
- if (this._r15BoneOverrides) this._r15BoneOverrides.clear();
- this._r15Animator = null;
- this._r15Skeleton = null;
- this._isR15 = false;
-
- // Удаляем модель
- if (this._modelRoot) {
- for (const m of this._modelMeshes) {
- try { m.dispose(); } catch (e) { /* ignore */ }
- }
- try { this._modelRoot.dispose(); } catch (e) { /* ignore */ }
- this._modelRoot = null;
- this._modelMeshes = [];
- }
-
- if (this.camera) {
- this.camera.dispose();
- this.camera = null;
- }
-
- if (this._audioCtx) {
- try { this._audioCtx.close(); } catch (e) { /* ignore */ }
- this._audioCtx = null;
- }
- }
-
- isActive() {
- return this._active;
- }
-
- // === ВНУТРЕННЕЕ ===
-
- /** Позиция камеры в мире (зависит от режима first/third/front). */
- _computeCameraPos() {
- // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up
- // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем»
- // высоту чтобы камера не дёргалась рывком при step-up.
- const visY = this._pos.y - this._stepUpVisualOffset;
- if (this._cameraMode === 'first') {
- return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z);
- }
- if (this._cameraMode === 'sideview') {
- // Кубикон Dash: камера сбоку, фиксированный yaw на куб.
- // Игрок движется по +X, камера в -Z от него (смотрит на +Z).
- // С этого ракурса +X на экране = вправо (как в Geometry Dash).
- // Лёгкое смещение camera по X влево от куба — игрок в левой
- // трети кадра, впереди видно больше уровня.
- return new Vector3(
- this._pos.x - 1.5,
- visY + SIDEVIEW_HEIGHT,
- this._pos.z - SIDEVIEW_DIST
- );
- }
- // forward — направление «куда смотрит игрок» с учётом yaw и pitch
- const cosP = Math.cos(this._pitch);
- const fx = Math.sin(this._yaw) * cosP;
- const fy = -Math.sin(this._pitch);
- const fz = Math.cos(this._yaw) * cosP;
- const dist = this._thirdDistance;
-
- // Точка «глаз» игрока — отсюда пускаем луч к запланированной
- // позиции камеры и сокращаем дистанцию если упёрлись в стену.
- const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET;
-
- if (this._cameraMode === 'third') {
- const desired = new Vector3(
- this._pos.x - fx * dist,
- eyeY - fy * dist,
- this._pos.z - fz * dist
- );
- return this._clampCameraToWorld(
- this._pos.x, eyeY, this._pos.z, desired
- );
- }
- // 'front' — спереди игрока, направлена назад (на лицо)
- const desiredFront = new Vector3(
- this._pos.x + fx * dist,
- eyeY + fy * dist,
- this._pos.z + fz * dist
- );
- return this._clampCameraToWorld(
- this._pos.x, eyeY, this._pos.z, desiredFront
- );
- }
-
- /**
- * Не позволяет камере «проходить» сквозь стены/блоки/примитивы.
- * Пускает луч от глаз игрока до запланированной позиции камеры.
- * Если на пути есть препятствие — возвращает точку чуть ближе
- * (hit.distance - PADDING), чтобы камера прижалась к стене.
- *
- * Игнорирует:
- * - меши без metadata (вспомогательная техника редактора),
- * - триггеры (canCollide===false),
- * - саму модель игрока,
- * - debris/particles.
- */
- _clampCameraToWorld(ex, ey, ez, desired) {
- if (!this.scene) return desired;
- // Вектор от глаз до желаемой камеры.
- const dx = desired.x - ex;
- const dy = desired.y - ey;
- const dz = desired.z - ez;
- const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
- if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт
- const dir = new Vector3(dx / len, dy / len, dz / len);
- const origin = new Vector3(ex, ey, ez);
- const ray = new Ray(origin, dir, len);
-
- const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась»
- const playerRoot = this._modelRoot;
-
- const pickPred = (mesh) => {
- if (!mesh) return false;
- if (!mesh.isEnabled || !mesh.isEnabled()) return false;
- // Прозрачно-визуальные и technical meshes — пропускаем.
- if (mesh.isPickable === false) return false;
- const md = mesh.metadata || {};
- // Триггеры/невидимки/скриптовые маркеры — не блокируют.
- if (md.canCollide === false) return false;
- if (md._isTriggerHelper) return false;
- // Модель игрока (камера не должна цепляться за собственный меш).
- if (playerRoot) {
- let n = mesh;
- while (n) {
- if (n === playerRoot) return false;
- n = n.parent;
- }
- }
- // Жидкости (вода/лава) — не блокируют камеру.
- if (md._liquidProxy) return false;
- return true;
- };
-
- let hit = null;
- try {
- hit = this.scene.pickWithRay(ray, pickPred);
- } catch (e) {
- return desired;
- }
- if (!hit || !hit.hit || hit.distance >= len - 0.01) {
- return desired;
- }
- // Сокращаем дистанцию.
- const clampedLen = Math.max(0.3, hit.distance - PADDING);
- return new Vector3(
- ex + dir.x * clampedLen,
- ey + dir.y * clampedLen,
- ez + dir.z * clampedLen
- );
- }
-
- // ===== Управление камерой из скрипта (Фаза 5.7) =====
-
- /** Установить угол обзора камеры (FOV) в градусах. */
- setCameraFov(degrees) {
- if (!this.camera) return;
- const d = Number(degrees);
- if (!Number.isFinite(d) || d < 10 || d > 130) return;
- this.camera.fov = d * Math.PI / 180;
- }
-
- /**
- * Привязать камеру к объекту — она смотрит на него.
- * getTarget — функция, возвращающая {x,y,z} цели.
- * opts: { distance, height } — отступ камеры от цели.
- */
- cameraFocusOn(getTarget, opts = {}) {
- if (typeof getTarget !== 'function') return;
- this._cameraOverride = {
- mode: 'focus',
- getTarget,
- distance: Number.isFinite(opts.distance) ? opts.distance : 8,
- height: Number.isFinite(opts.height) ? opts.height : 4,
- };
- }
-
- /**
- * Катсцена — плавный пролёт камеры по точкам.
- * points — массив {x,y,z} позиций камеры.
- * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию).
- * segDuration — секунд на отрезок между точками.
- * onDone — колбэк по завершении.
- */
- cameraCutscene(points, lookAt, segDuration, onDone) {
- if (!Array.isArray(points) || points.length < 2) return;
- this._cameraOverride = {
- mode: 'cutscene',
- points,
- lookAt: Array.isArray(lookAt) ? lookAt : [],
- segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2,
- t: 0, // время от начала
- seg: 0, // текущий отрезок
- onDone: typeof onDone === 'function' ? onDone : null,
- };
- }
-
- /** Вернуть камеру под управление игрока. */
- cameraReset() {
- this._cameraOverride = null;
- }
-
- /** Применить активный режим камеры скрипта (вызывается в _tick). */
- _applyCameraOverride(dt) {
- const o = this._cameraOverride;
- if (!o || !this.camera) return;
- if (o.mode === 'focus') {
- const t = o.getTarget();
- if (!t) return;
- // Камера позади-сверху цели, смотрит на неё.
- this.camera.position.set(t.x, t.y + o.height, t.z + o.distance);
- this.camera.setTarget(new Vector3(t.x, t.y, t.z));
- } else if (o.mode === 'cutscene') {
- // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB)
- // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается»
- // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт
- // ровно свою длительность независимо от лагов.
- o.t += Math.min(dt, 1 / 30);
- const segCount = o.points.length - 1;
- // Прогресс по текущему отрезку [0..1].
- let local = o.t / o.segDuration;
- let seg = Math.floor(o.t / o.segDuration);
- if (seg >= segCount) {
- // Катсцена завершена — встаём на последнюю точку.
- const last = o.points[o.points.length - 1];
- this.camera.position.set(last.x, last.y, last.z);
- const lookLast = o.lookAt[o.lookAt.length - 1];
- if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z));
- const cb = o.onDone;
- this._cameraOverride = null;
- if (cb) { try { cb(); } catch (e) { /* ignore */ } }
- return;
- }
- local = local - seg; // дробная часть = прогресс отрезка
- // Сглаживание (ease-in-out) — плавный пролёт.
- const k = local < 0.5
- ? 2 * local * local
- : 1 - Math.pow(-2 * local + 2, 2) / 2;
- const a = o.points[seg], b = o.points[seg + 1];
- this.camera.position.set(
- a.x + (b.x - a.x) * k,
- a.y + (b.y - a.y) * k,
- a.z + (b.z - a.z) * k,
- );
- // Точка взгляда — интерполяция между соседними lookAt.
- const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg];
- if (la && lb) {
- this.camera.setTarget(new Vector3(
- la.x + (lb.x - la.x) * k,
- la.y + (lb.y - la.y) * k,
- la.z + (lb.z - la.z) * k,
- ));
- }
- }
- }
-
- /**
- * Применить текущий режим камеры:
- * - В 1st person скрываем модель игрока (видим только сцену)
- * - В 3rd person и front показываем
- */
- _applyCameraMode() {
- const visible = this._cameraMode !== 'first';
- for (const m of this._modelMeshes) {
- m.setEnabled(visible);
- }
- // Сообщаем оружию что режим камеры сменился — чтобы перепарентить
- // view-model (камера в 1-st, модель игрока в 3-rd).
- if (this._scene3d?.weapons?.onCameraModeChange) {
- this._scene3d.weapons.onCameraModeChange(this._cameraMode);
- }
- }
-
- /**
- * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели.
- *
- * Установить override-rotation для именованного меша поверх анимации.
- * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion,
- * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет
- * наши углы Эйлера.
- *
- * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...).
- * Без префикса 'player_'.
- * rotation — Vector3 углов Эйлера (rad). null → снять override.
- *
- * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты).
- */
- setMeshRotationOverride(meshName, rotation) {
- // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт
- // этот метод для позы/замаха — переадресуем на override кости.
- // R15Animator каждый кадр ставит rest+анимацию; override кости
- // применяется поверх в _applyR15BoneOverrides() после update().
- if (this._isR15) {
- if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map();
- // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость.
- const lower = (meshName || '').toLowerCase();
- const logical = (lower.includes('right')) ? 'RightUpperArm'
- : (lower.includes('left')) ? 'LeftUpperArm'
- : 'RightUpperArm';
- if (rotation == null) {
- this._r15BoneOverrides.delete(logical);
- } else {
- this._r15BoneOverrides.set(logical,
- rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
- }
- return;
- }
- if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map();
- if (rotation == null) {
- this._meshRotationOverrides.delete(meshName);
- } else {
- this._meshRotationOverrides.set(meshName,
- rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
- }
- }
-
- /**
- * Применить override-повороты костей R15 поверх процедурной анимации.
- * Вызывается после R15Animator.update(). Используется WeaponSystem
- * для позы руки с оружием / melee-замаха.
- */
- _applyR15BoneOverrides() {
- const map = this._r15BoneOverrides;
- if (!map || map.size === 0 || !this._r15Skeleton) return;
- for (const [logical, rot] of map.entries()) {
- const bone = this._r15Skeleton.resolveBone(logical);
- if (!bone) continue;
- // Override задаётся как абсолютный локальный поворот кости
- // (Эйлер). Перекрывает то, что поставил аниматор этим кадром.
- const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z);
- bone.setRotationQuaternion(q, Space.LOCAL);
- }
- }
-
- /** Получить меш модели по короткому имени (без префикса 'player_'). */
- getModelMesh(meshName) {
- if (!this._modelMeshes) return null;
- const target = `player_${meshName}`;
- return this._modelMeshes.find(m => m.name === target) || null;
- }
-
- /**
- * Применить все активные override'ы. Вызывается каждый кадр в _tick
- * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после
- * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его
- * каждый кадр и пишем в .rotation.
- */
- _applyMeshRotationOverrides() {
- const map = this._meshRotationOverrides;
- if (!map || map.size === 0) return;
- for (const [meshName, rot] of map.entries()) {
- const mesh = this.getModelMesh(meshName);
- if (!mesh) continue;
- if (mesh.rotationQuaternion) {
- mesh.rotationQuaternion = null;
- }
- mesh.rotation.x = rot.x;
- mesh.rotation.y = rot.y;
- mesh.rotation.z = rot.z;
- }
- }
-
- /**
- * Включить/выключить «позу с оружием».
- * Делает 2 вещи независимо:
- * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney).
- * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает
- * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0,
- * и дуло автоматически смотрит вперёд персонажа.
- *
- * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять
- * ориентацию повёрнутого меша руки.
- */
- _updateExtendedArm(hasWeapon) {
- // === R15-скин: якорь оружия на кости RightHand ===
- // У R15 нет меша-руки — есть кость. Якорь привязываем к кости
- // через attachToBone: оружие следует за рукой при анимации.
- if (this._isR15 && this._r15Skeleton) {
- const showWeapon = hasWeapon && this._cameraMode !== 'first';
- if (showWeapon && !this._weaponAnchor) {
- const handBone = this._r15Skeleton.resolveBone('RightHand');
- const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0];
- if (handBone && skinMesh) {
- this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
- // attachToBone — якорь следует за костью каждый кадр.
- this._weaponAnchor.attachToBone(handBone, skinMesh);
- // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье.
- this._weaponAnchor.position.set(0, 0.05, 0.1);
- }
- }
- if (this._weaponAnchor) {
- this._weaponAnchor.setEnabled(showWeapon);
- }
- return;
- }
-
- const armMesh = this._rightArmMeshes?.[0];
- if (!armMesh) return;
- const meshName = (armMesh.name || '').replace(/^player_/, '');
- const showWeapon = hasWeapon && this._cameraMode !== 'first';
-
- // 1) Поза руки через override
- if (showWeapon) {
- this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0));
- } else {
- this.setMeshRotationOverride(meshName, null);
- }
-
- // 2) ChIstый якорь для оружия — TransformNode на плече персонажа,
- // ориентация совпадает с _modelRoot (без поворотов).
- if (showWeapon && !this._weaponAnchor && this._modelRoot) {
- this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
- this._weaponAnchor.parent = this._modelRoot;
- // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша.
- // Плечо: y = origin + 0.7 (выше).
- // X: сдвигаем чуть наружу (ещё правее).
- const sx = -(armMesh.position?.x ?? -0.4) + 0.15;
- const sy = (armMesh.position?.y ?? 1.1) + 0.7;
- const sz = (armMesh.position?.z ?? 0) + 0.95;
- this._weaponAnchor.position.set(sx, sy, sz);
- }
- if (this._weaponAnchor) {
- this._weaponAnchor.setEnabled(showWeapon);
- }
- }
-
- getWeaponAnchor() { return this._weaponAnchor || null; }
-
- /** Цикл first ↔ third. */
- _toggleCameraMode() {
- const idx = CAMERA_MODES.indexOf(this._cameraMode);
- this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
- this._applyCameraMode();
- // При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock)
- if (this._cameraMode === 'first') {
- this._requestPointerLockSafe();
- } else if (!this._shiftLock && document.pointerLockElement === this.canvas) {
- try { document.exitPointerLock(); } catch (e) {}
- }
- this._applyCursorVisibility?.();
- }
-
- /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
- * всегда лицом к камере, камера через плечо).
- */
- setShiftLock(on) {
- this._shiftLock = !!on;
- if (this._shiftLock) {
- // Запросить pointer-lock — курсор в центре
- this._requestPointerLockSafe();
- } else {
- // Снять lock если он есть и нет других причин держать (first/sideview)
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview'
- );
- if (!needPermLock && document.pointerLockElement === this.canvas) {
- try { document.exitPointerLock(); } catch (e) {}
- }
- }
- this._applyCursorVisibility?.();
- }
- isShiftLock() { return !!this._shiftLock; }
-
- /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
- * Не блокирует Esc/Tab/Enter (нужны для GUI).
- * Также сбрасывает накопленные клавиши чтобы движение остановилось. */
- setInputBlocked(blocked) {
- this._inputBlocked = !!blocked;
- if (this._inputBlocked) {
- try { this._codes?.clear(); } catch (e) {}
- this._shift = false;
- // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
- try {
- if (document.pointerLockElement === this.canvas) document.exitPointerLock();
- } catch (e) {}
- }
- }
- isInputBlocked() { return !!this._inputBlocked; }
-
- /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
- setCameraFrozen(frozen) {
- this._cameraFrozen = !!frozen;
- }
- isCameraFrozen() { return !!this._cameraFrozen; }
-
- /** Задача 04: снимок состояния камеры — для восстановления после модала. */
- captureCameraState() {
- return {
- yaw: this._yaw,
- pitch: this._pitch,
- cameraMode: this._cameraMode,
- thirdDistance: this._thirdDistance,
- fov: this.scene?.activeCamera?.fov,
- playerPos: this._pos ? {
- x: this._pos.x, y: this._pos.y, z: this._pos.z
- } : null,
- };
- }
-
- /** Задача 04: восстановить состояние камеры из снимка. */
- restoreCameraState(s) {
- if (!s) return;
- if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
- if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
- if (s.cameraMode) {
- this._cameraMode = s.cameraMode;
- try { this._applyCameraMode?.(); } catch (e) {}
- }
- if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
- if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
- this.scene.activeCamera.fov = s.fov;
- }
- }
-
- /** Задача 04: камера-фокус на reference (cube/npc/cam-target).
- * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
- * Использует уже существующий механизм camera.focus в GameRuntime, но
- * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
- * и зум на distance. */
- focusOnTarget(ref, opts) {
- opts = opts || {};
- const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
- const height = Number.isFinite(opts.height) ? opts.height : 3;
- const fov = Number.isFinite(opts.fov) ? opts.fov : null;
- let target = null;
- if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
- target = ref;
- } else {
- const m = this._resolveTargetMesh(ref);
- if (m) {
- const p = m.getAbsolutePosition?.() || m.position;
- target = { x: p.x, y: p.y, z: p.z };
- }
- }
- if (!target) return;
- // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
- // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
- if (!this._pos) return;
- const dx = target.x - this._pos.x;
- const dz = target.z - this._pos.z;
- const dy = target.y - this._pos.y;
- const horiz = Math.hypot(dx, dz);
- this._yaw = Math.atan2(dx, dz);
- this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
- this._thirdDistance = distance;
- if (this._cameraMode !== 'third') {
- this._cameraMode = 'third';
- try { this._applyCameraMode?.(); } catch (e) {}
- }
- if (fov && this.scene?.activeCamera) {
- this.scene.activeCamera.fov = fov * Math.PI / 180;
- }
- }
-
- _resolveTargetMesh(ref) {
- if (!ref) return null;
- if (ref.getScene && typeof ref.getScene === 'function') return ref;
- const sc = this._scene3d || this.scene3d;
- const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
- if (!idStr || !sc) return null;
- const tries = [
- () => sc.primitiveManager?.getMesh?.(idStr),
- () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
- () => sc.scene?.getMeshByName?.(idStr),
- () => sc.npcManager?.getMeshes?.(idStr)?.[0],
- ];
- for (const fn of tries) {
- try { const r = fn(); if (r) return r; } catch (e) {}
- }
- return null;
- }
-
- /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
- setCameraZoom(distance) {
- const d = Number(distance);
- if (!Number.isFinite(d)) return;
- this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
- Math.min(this.THIRD_DISTANCE_MAX, d));
- // Авто-переход third↔first если пересекли порог
- if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
- && this._cameraMode === 'third') {
- this._cameraMode = 'first';
- this._applyCameraMode?.();
- this._requestPointerLockSafe();
- } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
- && this._cameraMode === 'first' && !this._lockFirstPerson) {
- this._cameraMode = 'third';
- this._applyCameraMode?.();
- if (!this._shiftLock && document.pointerLockElement === this.canvas) {
- try { document.exitPointerLock(); } catch (e) {}
- }
- }
- }
- /** Установить границы зума колеса. */
- setCameraZoomLimits(min, max) {
- const mn = Number(min), mx = Number(max);
- if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
- if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
- // Перекламп текущей дистанции
- this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
- Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
- }
- /** Поведение мыши: default / lockcenter / lockcurrent.
- * default — свободный курсор (стандартный browser cursor).
- * lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y).
- * lockcurrent — pointer-lock, но без скрытия (визуально как default,
- * реально движение отслеживается через movementX/Y).
- */
- setMouseBehavior(mode) {
- if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
- this._mouseBehavior = mode;
- if (mode === 'default') {
- // Снимаем lock если ничто другое не требует его
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (!needPermLock && document.pointerLockElement === this.canvas) {
- try { document.exitPointerLock(); } catch (e) {}
- }
- } else {
- this._requestPointerLockSafe();
- }
- this._applyCursorVisibility?.();
- }
- /** Видимость курсора (для third без lock). */
- setMouseIconVisible(visible) {
- this._mouseIconVisible = !!visible;
- this._applyCursorVisibility?.();
- }
-
- _setupInput() {
- const canvas = this.canvas;
-
- const onCanvasClick = () => {
- // В UI-режиме клик не перехватывает мышь.
- if (this._uiCursorMode) return;
- if (!this._active) return;
- // Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор —
- // курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем
- // ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/
- // sideview/shiftLock), и только если по какой-то причине lock сняли
- // (например, юзер нажал Esc в first-режиме — надо вернуть lock).
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (!needPermLock) return;
- if (document.pointerLockElement !== canvas) {
- try {
- const p = canvas.requestPointerLock?.();
- if (p && typeof p.catch === 'function') p.catch(() => {});
- } catch (e) { /* ignore */ }
- }
- };
- canvas.addEventListener('click', onCanvasClick);
-
- // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
- // Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру.
- // Отпустил → курсор вернулся на ту же позицию (браузер сам ставит).
- const onCanvasMouseDownGlobal = (e) => {
- if (!this._active || this._uiCursorMode) return;
- if (e.button !== 2) return; // только ПКМ
- // В режимах с постоянным lock'ом ПКМ ничего не делает
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (needPermLock) return;
- // Запрашиваем lock — теперь mouseMove будет крутить камеру.
- this._rmbHeld = true;
- if (document.pointerLockElement !== canvas) {
- try {
- const p = canvas.requestPointerLock?.();
- if (p && typeof p.catch === 'function') p.catch(() => {});
- } catch (err) { /* ignore */ }
- }
- e.preventDefault();
- };
- const onWindowMouseUpGlobal = (e) => {
- if (e.button !== 2) return;
- if (!this._rmbHeld) return;
- this._rmbHeld = false;
- // Отпускаем lock только если он был включён нами для orbit-камеры
- // (т.е. сейчас НЕ режим с постоянным lock).
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (needPermLock) return;
- if (document.pointerLockElement === canvas) {
- try { document.exitPointerLock(); } catch (err) { /* ignore */ }
- }
- };
- canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
- window.addEventListener('mouseup', onWindowMouseUpGlobal);
- // Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger).
- canvas.addEventListener('contextmenu', (e) => {
- if (this._active) e.preventDefault();
- });
-
- // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
- const onCanvasMouseDown = (e) => {
- if (!this._uiCursorMode) return;
- if (typeof this._uiMouseDownCb !== 'function') return;
- const rect = canvas.getBoundingClientRect();
- const x = (e.clientX - rect.left) / rect.width;
- const y = (e.clientY - rect.top) / rect.height;
- if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
- try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ }
- }
- };
- const onCanvasMouseUp = (e) => {
- if (!this._uiCursorMode) return;
- if (typeof this._uiMouseUpCb !== 'function') return;
- const rect = canvas.getBoundingClientRect();
- const x = (e.clientX - rect.left) / rect.width;
- const y = (e.clientY - rect.top) / rect.height;
- try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ }
- };
- canvas.addEventListener('mousedown', onCanvasMouseDown);
- // mouseup ловим на document — мышь могла уйти за пределы канваса
- document.addEventListener('mouseup', onCanvasMouseUp);
-
- const onMouseMove = (e) => {
- // === UI-режим: транслируем нормализованные [0..1] координаты ===
- // подписчику (Worker через GameRuntime). Используется для drag-игр
- // типа Дальгона.
- if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') {
- const rect = canvas.getBoundingClientRect();
- const x = (e.clientX - rect.left) / rect.width;
- const y = (e.clientY - rect.top) / rect.height;
- // Кидаем только если внутри канваса
- if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
- try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ }
- }
- }
- if (document.pointerLockElement !== canvas) return;
- // Кубикон Dash: в sideview мышь не вращает камеру.
- if (this._cameraMode === 'sideview') return;
- // Задача 04: модал с freezeCamera — мышь не вращает.
- if (this._cameraFrozen) return;
- this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
- this._pitch += e.movementY * this.MOUSE_SENSITIVITY;
- const lim = Math.PI / 2 - 0.05;
- if (this._pitch > lim) this._pitch = lim;
- if (this._pitch < -lim) this._pitch = -lim;
- };
- document.addEventListener('mousemove', onMouseMove);
-
- // Колесо: zoom в third + авто-переключение third ↔ first.
- // Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person
- // (с pointer-lock). Колесо наружу из first → возврат в third.
- const onWheel = (e) => {
- if (!this._active) return;
- if (this._cameraMode === 'sideview') return;
- // Задача 04: модал с freezeCamera — колесо не зумит.
- if (this._cameraFrozen) { e.preventDefault(); return; }
- // В first-режиме колесо вверх НЕ работает (если lockfirst), вниз
- // выходит обратно в third (если zoomable first, не lockfirst).
- if (this._cameraMode === 'first') {
- if (this._lockFirstPerson) { e.preventDefault(); return; }
- if (e.deltaY > 0) {
- // Колесо вниз → отдалить → переход в third
- this._cameraMode = 'third';
- this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5;
- this._applyCameraMode?.();
- // Снять pointer-lock — в third без shift-lock курсор виден
- if (!this._shiftLock && document.pointerLockElement === canvas) {
- try { document.exitPointerLock(); } catch (err) {}
- }
- }
- e.preventDefault();
- return;
- }
- if (this._cameraMode !== 'third') return;
- // Шаг зума — пропорционален текущей дистанции (экспоненциальный фил)
- const step = Math.max(0.3, this._thirdDistance * 0.15);
- this._thirdDistance += Math.sign(e.deltaY) * step;
- if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
- if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
- // Авто-переход в first при близком зуме (Roblox-style)
- if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) {
- this._cameraMode = 'first';
- this._applyCameraMode?.();
- // Запросить pointer-lock — first всегда залочен
- if (!this._shiftLock && document.pointerLockElement !== canvas) {
- this._requestPointerLockSafe();
- }
- }
- e.preventDefault();
- };
- canvas.addEventListener('wheel', onWheel, { passive: false });
-
- let wasLocked = false;
- const onPointerLockChange = () => {
- const locked = document.pointerLockElement === canvas;
- if (locked) {
- wasLocked = true;
- this._rmbHeld = true; // если попал в lock — ПКМ удерживается
- } else if (wasLocked && this._active) {
- // pointer-lock снят. Причин три:
- // 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui'))
- // 2) ПКМ отпущена в third-person (orbit-камера завершена)
- // 3) Esc → выход из Play (если был в first/lockfirst/sideview)
- this._rmbHeld = false;
- if (this._uiCursorMode) {
- this._applyCursorVisibility();
- return;
- }
- const needPermLock = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._cameraMode === 'sideview' ||
- this._shiftLock
- );
- if (needPermLock) {
- // Был режим с постоянным lock'ом и его сняли → Esc → выход
- if (this._onExitRequest) this._onExitRequest();
- } else {
- // Third-person: пользователь просто отпустил ПКМ. Курсор
- // возвращается там же где был — это нормально, остаёмся в Play.
- this._applyCursorVisibility();
- }
- }
- };
- document.addEventListener('pointerlockchange', onPointerLockChange);
-
- const isTypingTarget = (target) => {
- if (!target) return false;
- const tag = (target.tagName || '').toLowerCase();
- if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
- return !!target.isContentEditable;
- };
- const onKeyDown = (e) => {
- if (!this._active) return;
- if (isTypingTarget(e.target)) return;
- // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
- // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
- // в third (без pointer-lock) сразу выходил из Play.
- if (e.code === 'Escape') {
- if (this._onExitRequest) {
- this._onExitRequest();
- return;
- }
- }
- // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
- // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
- // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
- if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
- // Глотаем preventDefault только для игровых клавиш
- if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
- return;
- }
- this._codes.add(e.code);
- if (e.shiftKey) this._shift = true;
- // C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
- // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
- if (e.code === 'KeyC') {
- const inGdMode = (this._autoRunSpeed || 0) > 0
- || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
- if (!inGdMode) this._toggleCameraMode();
- }
- // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
- // поэтому переназначено на L). Курсор центрируется, корпус всегда
- // лицом к камере, камера через плечо.
- if (e.code === 'KeyL') {
- this.setShiftLock(!this._shiftLock);
- }
- // B — встроенный магазин скинов (задача 07). Открывается только если
- // включён в проекте (scene.skins.shopVisible). Toggle.
- if (e.code === 'KeyB' && !this._inputBlocked) {
- try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
- }
- // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
- if (e.code === 'Tab') {
- e.preventDefault();
- this.setUiCursorMode(!this._uiCursorMode);
- }
- if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
- e.preventDefault();
- }
- // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус),
- // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек.
- const inGdMode = (this._autoRunSpeed || 0) > 0
- || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
- if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) {
- e.preventDefault();
- }
- };
- const onKeyUp = (e) => {
- if (isTypingTarget(e.target)) return;
- this._codes.delete(e.code);
- if (!e.shiftKey) this._shift = false;
- };
- window.addEventListener('keydown', onKeyDown);
- window.addEventListener('keyup', onKeyUp);
-
- const onBlur = () => {
- this._codes.clear();
- this._shift = false;
- };
- window.addEventListener('blur', onBlur);
-
- this._listeners = [
- { target: canvas, type: 'click', fn: onCanvasClick },
- { target: canvas, type: 'wheel', fn: onWheel,
- opts: { passive: false } },
- { target: document, type: 'mousemove', fn: onMouseMove },
- { target: document, type: 'pointerlockchange', fn: onPointerLockChange },
- { target: window, type: 'keydown', fn: onKeyDown },
- { target: window, type: 'keyup', fn: onKeyUp },
- { target: window, type: 'blur', fn: onBlur },
- ];
- }
-
- _tick() {
- // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая
- // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж
- // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1
- // (max 10 кадров/сек физики). Этого хватает чтобы движение было
- // плавным даже на 5 FPS — просто чуть рывками.
- let dt = this.scene.getEngine().getDeltaTime() / 1000;
- if (dt <= 0) return;
- if (dt > 0.1) dt = 0.1;
-
- // === Присед: по Ctrl на десктопе, или через мобильную кнопку
- // (которая шлёт keydown 'ControlLeft'). C — НЕ используется
- // (это смена вида в Babylon).
- // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику
- // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship).
- const inGdMode = (this._autoRunSpeed || 0) > 0
- || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
- const wantCrouch = !inGdMode && this._codes
- && (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
- if (wantCrouch && !this._crouching) {
- this._crouching = true;
- // сдвигаем центр капсулы вниз — низ ног остаётся на земле
- const dH = this.HALF_H_CROUCH - this.HALF_H;
- this.HALF_H = this.HALF_H_CROUCH;
- if (this._pos) this._pos.y += dH;
- } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
- this._crouching = false;
- const dH = this.HALF_H_NORMAL - this.HALF_H;
- this.HALF_H = this.HALF_H_NORMAL;
- if (this._pos) this._pos.y += dH;
- }
-
- // === Горизонтальное движение ===
- const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
- const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
- const isSprinting = this._shift;
- const speedMult = isSprinting ? this.SPRINT_MULT : 1;
- const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
-
- let moveX = 0, moveZ = 0;
- // c (codes) используется ниже для прыжка/Space — объявляем здесь,
- // чтобы был доступен после if/else движения.
- const c = this._codes;
- const am = this._analogMove;
- // === Кубикон Dash: авто-движение по +X, ввод заблокирован ===
- // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме
- // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются.
- if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) {
- moveX = this._autoRunSpeed * dt;
- moveZ = 0;
- } else
- // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z ===
- // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит
- // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО —
- // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал
- // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос
- // делает скрипт через player.teleport по X.
- if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) {
- moveX = 0;
- moveZ = this._autoRunSpeed * dt;
- } else
- // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами:
- // позволяет двигаться в любом направлении плавно, не только в 8 секторов.
- if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) {
- // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*).
- // *На практике мы передаём sprint через setVirtualShift.
- const mag = Math.min(1, Math.hypot(am.x, am.y));
- // Нормализуем направление, скорость = mag * speed
- const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y));
- const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y));
- const v = mag * speed;
- // y=1 → вперёд (forward), x=1 → вправо (right)
- moveX = forward.x * dirY * v + right.x * dirX * v;
- moveZ = forward.z * dirY * v + right.z * dirX * v;
- } else {
- if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; }
- if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; }
- if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; }
- if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; }
- }
-
- const isMoving = (moveX !== 0 || moveZ !== 0);
-
- // === ЛЁД: инерция движения ===
- // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость
- // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр)
- // с коэффициентом ускорения, и затухает с (1 - friction*dt*8).
- if (this._iceFriction > 0.001) {
- const fric = Math.min(1, this._iceFriction);
- // Чем выше friction, тем медленнее набираем скорость и тем дольше
- // затухает после отпускания клавиш.
- const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45
- const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1
- // Целевая скорость = moveX/moveZ (уже содержит dt)
- // Прибавляем разницу с ускорением
- this._iceVelX += (moveX - this._iceVelX) * accel;
- this._iceVelZ += (moveZ - this._iceVelZ) * accel;
- if (!isMoving) {
- this._iceVelX *= decay;
- this._iceVelZ *= decay;
- if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0;
- if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0;
- }
- moveX = this._iceVelX;
- moveZ = this._iceVelZ;
- } else {
- // Сбрасываем накопленную скорость когда лёд выкл
- this._iceVelX = 0;
- this._iceVelZ = 0;
- }
-
- // === Плавание (если AABB пересекает блок-воду) ===
- const inWater = this._isInWater();
- const submerged = this._isSubmerged();
- if (inWater) {
- // В воде движемся в 2 раза медленнее
- moveX *= 0.5;
- moveZ *= 0.5;
- }
-
- // === Вертикальное ===
- if (inWater) {
- // Плавание: лёгкая гравитация + плавучесть к поверхности
- const buoyancy = submerged ? 6 : 0;
- const swimGravity = -3;
- this._vy += (buoyancy + swimGravity) * dt;
- this._vy *= Math.max(0, 1 - 3 * dt);
- if (this._codes.has('Space')) this._vy += 14 * dt;
- if (this._vy > 4) this._vy = 4;
- if (this._vy < -4) this._vy = -4;
- } else {
- // Кубикон Dash: умеренная усиленная гравитация для коротких
- // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с
- // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2,
- // не слишком улетает по X (~4-5м за прыжок).
- // Variable-jump (отпускание Space обрезает vy) НЕ используется —
- // в авто-беге игрок не контролирует длительность нажатия.
- //
- // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ
- // к потолку. Кап-скорости тоже инвертируется.
- const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0;
- const userGravityMul = this._gravityMul || 1;
- const gDir = this._gravityDir || 1;
-
- if (this._waveMode) {
- // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|).
- // Гравитация полностью игнорируется — линейное движение по диагонали.
- const speed = Math.max(1, this._autoRunSpeed || 8);
- this._vy = this._jumpHeld ? speed : -speed;
- } else {
- this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt;
-
- // SHIP-режим: при удержании Space даём импульс ВВЕРХ
- // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс
- // делает плавный полёт.
- if (this._shipMode && this._jumpHeld) {
- // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался,
- // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36)
- const SHIP_THRUST = 80; // м/с² против гравитации
- this._vy += SHIP_THRUST * gDir * dt;
- }
- // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем
- // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается.
- if (this._robotMode && this._robotBoostLeft > 0) {
- if (c.has('Space')) {
- // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно.
- // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену).
- this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt;
- this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt);
- } else {
- this._robotBoostLeft = 0;
- }
- }
-
- // Cap: ±50 в любом направлении (для Ship — мягче, ±25)
- const vyCap = this._shipMode ? 25 : 50;
- if (this._vy < -vyCap) this._vy = -vyCap;
- if (this._vy > vyCap) this._vy = vyCap;
- }
- }
-
- const beforeX = this._pos.x, beforeZ = this._pos.z;
-
- // === STICK к движущейся платформе ===
- // Если в прошлом кадре стояли на примитиве/модели, и она двигается —
- // двигаем игрока вместе с ней (по дельте позиции платформы за кадр).
- if (this._lastGroundData && this._lastGroundPos) {
- const gd = this._lastGroundData;
- // Текущая позиция платформы — берём из live-data (она обновляется
- // movingPlatforms-скриптом через scene.move).
- const curX = gd.x, curY = gd.y, curZ = gd.z;
- const dPlatX = curX - this._lastGroundPos.x;
- const dPlatY = curY - this._lastGroundPos.y;
- const dPlatZ = curZ - this._lastGroundPos.z;
- // Применяем дельту только если она разумная (защита от телепорта
- // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.)
- if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) {
- this._pos.x += dPlatX;
- this._pos.y += dPlatY;
- this._pos.z += dPlatZ;
- }
- }
-
- // PERF-METRICS: замер физики игрока
- const _pt0 = performance.now();
- const result = this.physics.moveAABB(
- this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
- moveX, this._vy * dt, moveZ
- );
- const _bs = this._scene3d || this.scene3d;
- if (_bs && _bs._perfMetrics) {
- _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
- _bs._perfMetrics.physics_count++;
- }
- this._pos.set(result.x, result.y, result.z);
- if (result.hitY) this._vy = 0;
- // Surface-follow на smooth-terrain прижал нас к склону — гравитация
- // больше не нужна на этом кадре, иначе будет вибрация от соревнования
- // (гравитация тянет вниз → следующий кадр surface поднимает обратно).
- if (result.surfaceFollowed) this._vy = 0;
-
- // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы
- // визуально плавно подняться. Физика уже сделала телепорт вверх,
- // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже
- // в кадровой логике (раз за tick).
- if (result.steppedUpBy && result.steppedUpBy > 0) {
- this._stepUpVisualOffset += result.steppedUpBy;
- // Кэп — не более 1.2м, иначе при множественных степах подряд
- // оффсет может стать гигантским и аватар «уйдёт под землю».
- if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2;
- }
- // Спадание оффсета. dt — реальное время тика (секунды).
- if (this._stepUpVisualOffset > 0) {
- this._stepUpVisualOffset -= this._stepUpDecay * dt;
- if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0;
- }
-
- // Запоминаем «на чём стоим» для следующего кадра
- if (result.onGround && result.groundData?.data) {
- this._lastGroundData = result.groundData.data;
- this._lastGroundPos = {
- x: result.groundData.data.x,
- y: result.groundData.data.y,
- z: result.groundData.data.z,
- };
- } else {
- this._lastGroundData = null;
- this._lastGroundPos = null;
- }
-
- // === Авто-вылезание на берег из воды ===
- // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться —
- // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание».
- if (inWater && isMoving && (result.hitX || result.hitZ)) {
- this._vy = Math.max(this._vy, 5);
- }
-
- // Респавн если игрок упал в пустоту (за пределы baseplate)
- if (this._pos.y < -30) {
- const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
- this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z);
- this._vy = 0;
- }
-
- // === Push unanchored объектов при пересечении ===
- // Скорость игрока в этом кадре (для направления толчка).
- // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0,
- // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD)
- // и forward камеры — DynamicsManager выберет лучшее направление.
- const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt);
- const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt);
- const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ);
- const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal);
- // Берём "желаемое" движение если оно больше реального (игрок упёрся,
- // но WASD нажаты — значит "пытается толкать").
- const useDesired = desiredSpeed > realSpeed + 0.5;
- const pushVx = useDesired ? moveX / dt : playerVxReal;
- const pushVz = useDesired ? moveZ / dt : playerVzReal;
- if (this._scene3d?.dynamics?.isEnabled?.()) {
- this._scene3d.dynamics.applyPushFromPlayer(
- this._pos.x, this._pos.y, this._pos.z,
- this.HALF_W, this.HALF_H, this.HALF_D,
- pushVx, pushVz,
- forward.x, forward.z,
- playerVxReal, playerVzReal
- );
- }
-
- // === Чекпоинты — обновить точку спавна при касании ===
- if (this.physics?.getOverlappingPrimitives) {
- const overlaps = this.physics.getOverlappingPrimitives(
- this._pos.x, this._pos.y, this._pos.z,
- this.HALF_W, this.HALF_H, this.HALF_D
- );
- for (const data of overlaps) {
- if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) {
- this._activatedCheckpoints.add(data.id);
- if (this._scene3d) {
- // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта
- // чтобы при респавне игрок не попал внутрь чекпоинта.
- this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z);
- }
- this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь»)
- }
- }
- }
-
- // Шаги — копим пройденную горизонтальную дистанцию когда onGround
- if (result.onGround && isMoving) {
- const dxReal = this._pos.x - beforeX;
- const dzReal = this._pos.z - beforeZ;
- this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal);
- const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK;
- if (this._distanceSinceLastStep >= stepThreshold) {
- this._distanceSinceLastStep = 0;
- this._playFootstep();
- }
- } else {
- // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки
- // не воспроизвёлся слишком рано.
- this._distanceSinceLastStep = 0;
- }
-
- // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть.
- // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже
- // края, понимает что упал в яму, бесит. С коянот-окном — прощает.
- //
- // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом»,
- // используем result.onCeiling вместо onGround.
- const gDir = this._gravityDir || 1;
- const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false);
- if (effGround) {
- this._coyoteLeft = 0.12;
- } else if (this._coyoteLeft > 0) {
- this._coyoteLeft -= dt;
- }
- const canJump = effGround || this._coyoteLeft > 0;
- // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде.
- // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации).
- // В Ship-режиме обычный jump-импульс отключён — корабль управляется
- // только удержанием Space (см. блок _shipMode выше). _jumpHeld
- // обновляем чтобы остальная логика (release, double-jump) работала.
- if (this._waveMode) {
- // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block).
- // _jumpHeld для синхронизации с состоянием Space.
- this._jumpHeld = c.has('Space');
- } else
- if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
- if (!this._jumpHeld) {
- // Robot — стартовый импульс полный (как куб) для тапа достаточный,
- // boost-фаза 0.45с удлиняет подъём при удержании Space.
- this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
- this._playJumpSound();
- this._jumpHeld = true;
- this._coyoteLeft = 0;
- // Robot: запускаем boost-фазу на 0.45с
- if (this._robotMode) {
- this._robotBoostLeft = 0.45;
- }
- }
- } else if (this._shipMode && c.has('Space')) {
- this._jumpHeld = true;
- } else if (this._ufoMode && c.has('Space') && !inWater) {
- // UFO: каждый отдельный тап = микропрыжок (даже в воздухе).
- // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата.
- if (!this._jumpHeld) {
- this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85;
- this._playJumpSound();
- this._jumpHeld = true;
- }
- }
- // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка
- if (effGround) this._doubleJumpUsed = false;
- // Двойной прыжок: в воздухе и Space нажат отдельно (после release).
- if (!effGround && !inWater && c.has('Space')
- && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) {
- this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
- this._playJumpSound();
- this._doubleJumpUsed = true;
- this._jumpHeld = true;
- }
- if (!c.has('Space')) this._jumpHeld = false;
-
- // Звук всплеска при входе/выходе из воды
- if (this._wasInWater !== inWater) {
- this._playSplashSound();
- this._wasInWater = inWater;
- }
-
- // Состояние игрока для game.player.state ('ground'|'air'|'water').
- this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air');
- // Хук game.onPlayerLand — был в воздухе, коснулся земли.
- if (effGround && this._wasOnGround === false && !inWater) {
- if (typeof this._onLand === 'function') {
- try { this._onLand(); } catch (e) { /* ignore */ }
- }
- }
- this._wasOnGround = effGround;
-
- // === Камера ===
- this.camera.position = this._computeCameraPos();
- // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную.
- if (this._cameraOverride) {
- this._applyCameraOverride(dt);
- }
- // Camera shake — небольшое случайное смещение по X/Y, затухает.
- if (this._cameraShakeLeft > 0) {
- this._cameraShakeLeft -= dt;
- const t = Math.max(0, this._cameraShakeLeft);
- const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка
- this.camera.position.x += (Math.random() - 0.5) * amp;
- this.camera.position.y += (Math.random() - 0.5) * amp;
- }
- // Скрипт-управление камерой (cutscene/focus) уже выставило И
- // позицию, И направление взгляда через setTarget. НЕ трогаем
- // camera.rotation — иначе setTarget затирается и катсцена
- // «смотрит прямо» вместо вращения по точкам lookAt.
- if (!this._cameraOverride) {
- if (this._cameraMode === 'front') {
- // Камера смотрит назад на игрока — yaw на 180°, pitch инверт.
- this.camera.rotation.x = -this._pitch;
- this.camera.rotation.y = this._yaw + Math.PI;
- } else if (this._cameraMode === 'sideview') {
- // Sideview: камера в -Z от игрока, смотрит на +Z.
- this.camera.rotation.x = 0;
- this.camera.rotation.y = 0;
- this.camera.rotation.z = 0;
- } else {
- this.camera.rotation.x = this._pitch;
- this.camera.rotation.y = this._yaw;
- }
- if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0;
- }
-
- // === Модель игрока ===
- if (this._modelRoot) {
- // === Поза: в воде персонаж лежит горизонтально (плавает) ===
- // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция.
- const targetSwimTilt = inWater ? Math.PI / 2 : 0;
- const swimTilt = this._swimTilt ?? 0;
- const tiltStep = 4 * dt; // 4 рад/с скорость наклона
- let nextTilt = swimTilt;
- if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) {
- nextTilt = targetSwimTilt;
- } else {
- nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep;
- }
- this._swimTilt = nextTilt;
-
- // В воде наклон 90° кладёт модель горизонтально. yLift поднимает
- // root к центру AABB. Также при наклоне корень оказывается в позиции
- // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root
- // на длину модели вперёд по yaw чтобы голова была впереди.
- const tiltFrac = nextTilt / (Math.PI / 2); // 0..1
- const yLift = inWater ? this.HALF_H * tiltFrac : 0;
- const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела
- const fwdShift = inWater ? bodyLen * tiltFrac : 0;
- const fx = Math.sin(this._modelYaw);
- const fz = Math.cos(this._modelYaw);
- this._modelRoot.position.set(
- this._pos.x + fx * fwdShift,
- this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset,
- this._pos.z + fz * fwdShift
- );
-
- // Поворот модели:
- // - на суше: направление РЕАЛЬНОГО движения (как было).
- // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
- // двигает тело вбок без вращения, как на суше при first-person.
- if (inWater) {
- const targetYaw = this._yaw;
- let diff = targetYaw - this._modelYaw;
- while (diff > Math.PI) diff -= Math.PI * 2;
- while (diff < -Math.PI) diff += Math.PI * 2;
- const maxStep = this.MODEL_TURN_SPEED * dt * 2;
- if (Math.abs(diff) <= maxStep) {
- this._modelYaw = targetYaw;
- } else {
- this._modelYaw += Math.sign(diff) * maxStep;
- }
- } else {
- // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно
- // следует за yaw камеры (AutoRotate привязан к камере).
- // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения.
- const followCamera = (
- this._cameraMode === 'first' ||
- this._cameraMode === 'lockfirst' ||
- this._shiftLock
- );
- if (followCamera) {
- const targetYaw = this._yaw;
- let diff = targetYaw - this._modelYaw;
- while (diff > Math.PI) diff -= Math.PI * 2;
- while (diff < -Math.PI) diff += Math.PI * 2;
- const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе
- if (Math.abs(diff) <= maxStep) {
- this._modelYaw = targetYaw;
- } else {
- this._modelYaw += Math.sign(diff) * maxStep;
- }
- } else {
- const dxReal = this._pos.x - beforeX;
- const dzReal = this._pos.z - beforeZ;
- const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
- if (movedHorizontal) {
- const targetYaw = Math.atan2(dxReal, dzReal);
- let diff = targetYaw - this._modelYaw;
- while (diff > Math.PI) diff -= Math.PI * 2;
- while (diff < -Math.PI) diff += Math.PI * 2;
- const maxStep = this.MODEL_TURN_SPEED * dt;
- if (Math.abs(diff) <= maxStep) {
- this._modelYaw = targetYaw;
- } else {
- this._modelYaw += Math.sign(diff) * maxStep;
- }
- }
- }
- }
- // Применяем yaw + swim-tilt.
- // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит
- // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift).
- this._modelRoot.rotation.y = this._modelYaw;
- this._modelRoot.rotation.x = nextTilt;
- // В воде также добавляем лёгкое покачивание по Z (как волна тела)
- if (inWater) {
- const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15;
- this._modelRoot.rotation.z = wobble;
- } else if (this._cameraMode === 'sideview') {
- // Кубикон Dash: в воздухе куб крутится назад (по часовой если
- // смотреть с -Z), на земле плавно возвращается в 0.
- // Скорость подобрана так чтобы между прыжками куб успевал
- // совершить ~1 оборот.
- const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с
- if (!result.onGround) {
- this._dashSpinAngle -= SPIN_SPEED * dt;
- } else {
- // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0)
- const TAU = Math.PI * 2;
- const target = Math.round(this._dashSpinAngle / TAU) * TAU;
- const diff = target - this._dashSpinAngle;
- const snapStep = SPIN_SPEED * 1.5 * dt;
- if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target;
- else this._dashSpinAngle += Math.sign(diff) * snapStep;
- }
- this._modelRoot.rotation.z = this._dashSpinAngle;
- } else {
- this._modelRoot.rotation.z = 0;
- }
- // Поза с оружием — обновляем флаг каждый кадр (на случай смены)
- const hasWeapon = !!this._scene3d?.weapons?._equipped;
- this._updateExtendedArm(hasWeapon);
- // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций.
- // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем
- // и пишем свои углы Эйлера. Это ключевой момент: вызывается
- // каждый кадр, поэтому override переживает анимацию.
- this._applyMeshRotationOverrides();
- }
-
- // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)).
- // Применяем каждый кадр — на случай если меши только что асинхронно
- // загрузились, либо _applyCameraMode перезаписал enabled=true.
- if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) {
- for (let i = 0; i < this._modelMeshes.length; i++) {
- const m = this._modelMeshes[i];
- if (m && m.isEnabled && m.isEnabled() && m.setEnabled) {
- try { m.setEnabled(false); } catch (e) {}
- }
- }
- }
-
- // Тик распадающихся кусков игрока (после смерти)
- this._tickDebris(dt);
-
- // === Анимации ===
- // Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
- this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
- this._isGrounded = !!result.onGround;
-
- // Non-humanoid single-mesh скин: костей нет — анимируем процедурно
- // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
- if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
- this._animateNonHumanoidMesh(dt);
- return;
- }
-
- // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
- // Состояния: idle/walk/run/jump/fall. sprint → run.
- if (this._isR15 && this._r15Animator) {
- let r15State;
- if (!result.onGround) {
- // vy > 0 — вверх (jump-поза с поджатыми ногами),
- // vy < 0 — вниз (fall, лёгкий наклон корпуса).
- r15State = (this._vy > 0.5) ? 'jump' : 'fall';
- } else if (inWater) {
- r15State = isMoving ? 'walk' : 'idle';
- } else if (isMoving) {
- r15State = isSprinting ? 'run' : 'walk';
- } else {
- r15State = 'idle';
- }
- this._r15Animator.setState(r15State);
- this._r15Animator.update(dt);
- // Override костей поверх анимации (поза руки с оружием/замах).
- this._applyR15BoneOverrides();
- return; // R15 не использует AnimationGroups-путь ниже
- }
-
- let nextAnim;
- if (inWater) {
- // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе
- nextAnim = isMoving ? 'walk' : 'idle';
- } else if (!result.onGround) {
- nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle');
- } else if (isMoving) {
- nextAnim = isSprinting ? 'sprint' : 'walk';
- } else {
- nextAnim = 'idle';
- }
- // Снимок состояния для лога в _playAnim
- this._lastAnimDebug = {
- vy: this._vy,
- og: !!result.onGround,
- sf: !!result.surfaceFollowed,
- mv: !!isMoving,
- };
- // === Периодический trace состояния (раз в 30 кадров ~0.5с) ===
- // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация
- // не меняется — полезно для разбора "вибрации".
- if (!this._animTraceCnt) this._animTraceCnt = 0;
- this._animTraceCnt++;
- if (this._animTraceCnt >= 30) {
- this._animTraceCnt = 0;
- const d = this._lastAnimDebug;
- console.log(`[AnimTrace] anim=${this._currentAnim} `
- + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`);
- }
- this._playAnim(nextAnim);
- }
-}
+/**
+ * PlayerController — игрок в режиме Play (FPS-камера, гравитация, столкновения).
+ *
+ * Камера:
+ * 1st person — камера в позиции глаз игрока, модель невидима.
+ * 3rd person — камера сзади-сверху, модель видна.
+ * Переключение: клавиша C циклит first ↔ third.
+ * Колесо мыши в third-person — меняет дистанцию (zoom).
+ *
+ * Модель игрока:
+ * Грузим GLB через ModelManager (тот же что для editor-моделей).
+ * Корневой TransformNode хранит position/rotation модели.
+ * В 3rd person модель видна и крутится в направлении движения.
+ * Анимации (idle/walk/sprint) переключаются по скорости/спринту.
+ *
+ * Управление:
+ * W/A/S/D / стрелки — движение в горизонтальной плоскости
+ * Space — прыжок
+ * Shift — спринт (×1.7)
+ * C — переключить камеру 1st ↔ 3rd
+ * Esc — выйти из игры (через pointer-lock release)
+ */
+import {
+ Vector3, UniversalCamera, SceneLoader, TransformNode,
+ MeshBuilder, StandardMaterial, Color3,
+ Quaternion, Space, Ray,
+} from '@babylonjs/core';
+import { getModelType } from './ModelTypes';
+import { R15Skeleton } from './R15Skeleton';
+import { R15Animator } from './R15Animator';
+
+// Цикл режимов: первое лицо → за спиной → спереди (показ персонажа лицом).
+// 'front' удобен чтобы оценить скин: камера спереди, смотрит на лицо персонажа.
+const CAMERA_MODES = ['third', 'first', 'front'];
+// Для режима 'sideview' (Кубикон Dash):
+// - камера фиксирована сбоку (смотрит на +Z с расстояния SIDEVIEW_DIST по -Z)
+// - дистанция SIDEVIEW_DIST и высота SIDEVIEW_HEIGHT подобраны чтобы куб
+// и ~12м препятствий впереди влезали в кадр на 16:9.
+const SIDEVIEW_DIST = 14;
+const SIDEVIEW_HEIGHT = 2.5;
+
+export class PlayerController {
+ constructor(scene, canvas, physics, scene3d = null) {
+ this.scene = scene;
+ this.canvas = canvas;
+ this.physics = physics;
+ this._scene3d = scene3d; // BabylonScene-обёртка (для checkpoint → setSpawnPoint)
+ this._activatedCheckpoints = new Set(); // id чекпоинтов которые уже активировали
+
+ // AABB
+ this.HALF_W = 0.3;
+ this.HALF_H = 0.9;
+ this.HALF_D = 0.3;
+ this.EYE_HEIGHT = 0.7; // глаза от центра AABB
+
+ this.WALK_SPEED = 4.5;
+ this.SPRINT_MULT = 1.7;
+ this.JUMP_VELOCITY = 8;
+ this._jumpPowerMul = 1; // множитель силы прыжка (настраивается извне)
+ this._speedMul = 1; // множитель скорости передвижения
+ this._gravityMul = 1; // множитель гравитации (для GD-стиля нужна повышенная)
+ this._shipMode = false; // GD-гейммод Ship: тап-удержание = подъём (вертолёт)
+ this._ufoMode = false; // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе
+ this._waveMode = false; // GD-гейммод Wave: всегда движение под ±45° (Space зажат — вверх, отпущен — вниз)
+ this._robotMode = false; // GD-гейммод Robot: высота прыжка = длительность удержания Space (variable-jump)
+ this._robotBoostLeft = 0; // оставшееся время boost-фазы (с)
+ // Кубикон Dash: если > 0 — игрок САМ движется по +X со скоростью X м/с.
+ // Ввод (WASD/тач-джойстик) игнорируется в _tick. Прыжок остаётся.
+ this._autoRunSpeed = 0;
+ // Кубикон Dash: накопленный угол вращения куба вокруг Z (в воздухе).
+ // В sideview-камере при прыжке куб эффектно крутится — визитка GD.
+ this._dashSpinAngle = 0;
+ // Camera shake: amplitude + remaining time. Применяется в _tick после
+ // _computeCameraPos. Используется через game.camera.shake(amp, dur).
+ this._cameraShakeAmp = 0;
+ this._cameraShakeLeft = 0;
+ // Управление камерой из скрипта (Фаза 5.7). null = обычная камера
+ // игрока. Иначе объект режима:
+ // { mode:'focus', getTarget } — следить за объектом;
+ // { mode:'cutscene', points, durations, ... } — пролёт по точкам.
+ this._cameraOverride = null;
+ // Coyote-time: окно после схода с платформы когда ещё можно прыгнуть.
+ // Сглаживает жёсткие GD-таймиги. Сбрасывается на 0.12 при onGround.
+ this._coyoteLeft = 0;
+ this._doubleJumpEnabled = false;
+ this._doubleJumpUsed = false; // использован ли второй прыжок в текущем «полёте»
+ // Кубикон Dash: направление гравитации. +1 = нормально (вниз),
+ // -1 = инвертировано (вверх, как после blue orb / gravity portal в GD).
+ // Применяется только в sideview-режиме. Влияет на:
+ // - vy += GRAVITY * gravityDir * dt
+ // - jump: vy = JUMP_VELOCITY * gravityDir (вверх или вниз)
+ // - "onGround" определение: hitY + vy*gravityDir < 0
+ // Также куб-визуал переворачивается через _gravityDirVisual (см. moveCube в скрипте).
+ this._gravityDir = 1;
+ // Скользкость (лёд): 0 = нормальное мгновенное движение/остановка,
+ // 1 = полностью скользко (инерция держится бесконечно). Реалистичный
+ // лёд = ~0.85. Настраивается через game.player.setIceFriction(value).
+ this._iceFriction = 0;
+ this._iceVelX = 0;
+ this._iceVelZ = 0;
+ // Присед — уменьшает высоту AABB. Включается через
+ // game.player.setCrouch(true). HALF_H_NORMAL = 0.9, HALF_H_CROUCH = 0.45.
+ this._crouching = false;
+ this.HALF_H_NORMAL = 0.9;
+ this.HALF_H_CROUCH = 0.45;
+ this.GRAVITY = -22;
+ this.MOUSE_SENSITIVITY = 0.0025;
+
+ // 3rd person camera (Roblox-style: 0.5 .. 32)
+ this.THIRD_DISTANCE_MIN = 0.5;
+ this.THIRD_DISTANCE_MAX = 32;
+ this.THIRD_DISTANCE_DEFAULT = 5;
+ this.THIRD_HEIGHT_OFFSET = 1.0; // на сколько выше плеча игрока
+ // Порог перехода third ↔ first при зуме внутрь (Roblox: ~0.5)
+ this.FIRST_PERSON_ZOOM_THRESHOLD = 0.7;
+ // Lockfirst-режим: нельзя выйти из first-person зумом наружу
+ this._lockFirstPerson = false;
+ // Shift-Lock: курсор в центре, камера через плечо, корпус доворачивается к камере
+ // (включается клавишей L по дефолту, или game.player.setShiftLock(true))
+ this._shiftLock = false;
+ // Видимость курсора по умолчанию (game.input.setMouseIconVisible)
+ this._mouseIconVisible = true;
+ // Mouse behavior: 'default' (свободный) / 'lockcenter' (зафиксирован)
+ // / 'lockcurrent' (зафиксирован на текущей позиции)
+ this._mouseBehavior = 'default';
+ // Флаг: ПКМ зажата прямо сейчас (для orbit-камеры в third)
+ this._rmbHeld = false;
+
+ this.camera = null;
+ this._active = false;
+ this._onExitRequest = null;
+
+ // Состояние игрока
+ this._pos = new Vector3(0, 5, 0);
+ this._vy = 0;
+ this._yaw = 0;
+ this._pitch = 0;
+
+ // Камера. Дефолт — первое лицо (как в большинстве игр).
+ this._cameraMode = 'third';
+ this._thirdDistance = this.THIRD_DISTANCE_DEFAULT;
+
+ // Ввод
+ this._codes = new Set();
+ this._shift = false;
+
+ // Auto-step visual smoothing. Когда PhysicsAABB телепортирует
+ // игрока вверх на уступ (steppedUpBy), физически он уже наверху,
+ // но мы интерполируем рендер: показываем модель и камеру со сдвигом
+ // ВНИЗ на эту величину и за ~120мс плавно уменьшаем оффсет до 0.
+ // Получается визуально как плавный «полупрыжок» без рывка.
+ this._stepUpVisualOffset = 0;
+ // Скорость спадания оффсета (м/с). 4.5 м/с → 0.55м (макс. step) спадёт за ~120мс.
+ this._stepUpDecay = 4.5;
+
+ // Модель игрока (грузится в start)
+ // Дефолт — R15-скин bacon-hair (классический Roblox-вид).
+ this._modelTypeId = 'skin_bacon-hair';
+ this._modelRoot = null;
+ this._modelMeshes = [];
+ // Кубикон Dash: скрипт через game.player.setSkinVisible(false) выставляет
+ // _skinVisibleScripted = false. Это значение применяется КАЖДЫЙ КАДР
+ // в _tick (после анимаций), чтобы скрин оставался скрытым даже после
+ // асинхронной загрузки модели или после _applyCameraMode.
+ this._skinVisibleScripted = true;
+ this._animations = {};
+ this._currentAnim = null;
+ // Масштаб модели чтобы её рост соответствовал AABB (~1.8 = 2 блока).
+ // Kenney character GLB примерно 2.5 ед высотой → 1.8 / 2.5 ≈ 0.72.
+ this._modelScale = 0.72;
+
+ // === R15-скин (bacon-hair и др.) ===
+ // R15-скины — это glTF с встроенным скелетом Mixamo (без анимаций).
+ // Если _modelTypeId начинается с 'skin_' — грузим R15-скин из
+ // characters//body.glb, детектируем скелет, анимируем
+ // процедурно через R15Animator (см. _loadPlayerModel / _tick).
+ this._isR15 = false; // флаг: загружен валидный R15-скелет
+ this._r15Skeleton = null; // R15Skeleton — резолвер костей
+ this._r15Animator = null; // R15Animator — процедурные анимации
+ this._skinManifest = null; // кеш skins_manifest.json
+ this._skinOverrides = {}; // overrides текущего скина
+
+ // === Жизни игрока ===
+ this.maxHp = 100;
+ this.hp = 100;
+ this._lastDamageTime = 0;
+ this._invulnerabilityTime = 0.5; // 500мс i-frames после удара
+ this._onHpChange = null;
+ this._onDeath = null;
+
+ // Звук шагов — простой генератор через Web Audio API.
+ // Шаг проигрывается когда игрок прошёл по горизонтали STEP_DISTANCE.
+ this._audioCtx = null;
+ this._distanceSinceLastStep = 0;
+ this.STEP_DISTANCE_WALK = 1.6;
+ this.STEP_DISTANCE_SPRINT = 1.1;
+
+ // Угол поворота модели — следует за направлением движения, а не yaw камеры.
+ // Когда игрок стоит — сохраняем последний угол.
+ this._modelYaw = 0;
+ this.MODEL_TURN_SPEED = 12; // скорость доворота к нужному углу (рад/с)
+
+ this._listeners = [];
+ this._beforeRender = null;
+
+ // === Stick к движущейся платформе ===
+ // Если игрок стоит на примитиве/модели — следующий кадр сдвигает его
+ // на дельту движения этого объекта (его позиция могла измениться).
+ this._lastGroundData = null;
+ this._lastGroundPos = null;
+
+ // === Тач-режим (мобилки/планшеты) ===
+ // Если true — pointer-lock не запрашивается, mouse-listener не активен,
+ // ввод управляется снаружи через setVirtualKey/addCameraDelta.
+ this._touchMode = false;
+ // Фактическая скорость поворота камеры от тача (рад/пиксель).
+ this.TOUCH_SENSITIVITY = 0.005;
+ }
+
+ /**
+ * Включить тач-режим. Вызывать ДО start(), на тач-устройствах.
+ * В этом режиме:
+ * - pointer-lock НЕ запрашивается
+ * - mousemove игнорируется
+ * - keyboard всё ещё слушается (на случай Bluetooth-клавиатуры),
+ * но дополнительно работают setVirtualKey() / addCameraDelta().
+ */
+ setTouchMode(enabled) {
+ this._touchMode = !!enabled;
+ }
+
+ /**
+ * Установить «виртуально нажатую» клавишу. code как у KeyboardEvent.code:
+ * 'KeyW' | 'KeyA' | 'KeyS' | 'KeyD' | 'Space'
+ * Для шифта — отдельный параметр.
+ */
+ setVirtualKey(code, pressed) {
+ if (pressed) this._codes.add(code);
+ else this._codes.delete(code);
+ }
+
+ /** Программное нажатие/отпускание Shift (бег). */
+ setVirtualShift(pressed) {
+ this._shift = !!pressed;
+ }
+
+ /**
+ * Добавить дельту к yaw/pitch камеры (для тач-свайпа поверх 3D-сцены).
+ * dx, dy — пиксели свайпа.
+ */
+ addCameraDelta(dx, dy) {
+ this._yaw += dx * this.TOUCH_SENSITIVITY;
+ this._pitch += dy * this.TOUCH_SENSITIVITY;
+ const lim = Math.PI / 2 - 0.05;
+ if (this._pitch > lim) this._pitch = lim;
+ if (this._pitch < -lim) this._pitch = -lim;
+ }
+
+ /** Прыжок (один кадр) — пушим Space, в следующем кадре уберём. */
+ triggerJump() {
+ this._codes.add('Space');
+ // Через 100мс отпускаем — этого хватает контроллеру чтобы заметить
+ // нажатие и инициировать прыжок (он проверяет onGround + Space).
+ setTimeout(() => this._codes.delete('Space'), 100);
+ }
+
+ /**
+ * Аналоговый ввод движения для тач-джойстика.
+ * x, y ∈ [-1, 1] в локальной системе игрока: y=1 — вперёд (от камеры),
+ * x=1 — вправо. Магнитуда vector'а определяет скорость (0..walk..sprint).
+ *
+ * Если задано — используется ВМЕСТО KeyW/A/S/D в _tick. Чтобы вернуться
+ * к дискретным клавишам, передай null.
+ */
+ setAnalogMove(x, y) {
+ if (x === null || y === null) {
+ this._analogMove = null;
+ return;
+ }
+ if (!this._analogMove) this._analogMove = { x: 0, y: 0 };
+ this._analogMove.x = x;
+ this._analogMove.y = y;
+ }
+
+ setOnExitRequest(cb) {
+ this._onExitRequest = cb;
+ }
+
+ /** Установить тип модели персонажа — должен быть вызван ДО start(). */
+ setModelType(typeId) {
+ this._modelTypeId = typeId || 'character-a';
+ }
+
+ /**
+ * Сменить скин ИГРОКА в Play-режиме без перезапуска сцены (задача 07).
+ * Выгружает текущую модель/скелет/аниматор, сбрасывает AABB к дефолту,
+ * грузит новую модель (R15 или non-humanoid). Возвращает Promise.
+ *
+ * Используется из game.player.setSkin(slug).
+ */
+ async reloadSkin(typeId) {
+ if (!this._active) return false;
+ const newType = typeId || 'character-a';
+ if (newType === this._modelTypeId && this._modelRoot) return true; // уже этот скин
+ // 1) Выгрузить текущую модель и связанные аниматоры.
+ try {
+ if (this._modelRoot) { this._modelRoot.dispose(false, true); }
+ } catch (e) { /* ignore */ }
+ this._modelRoot = null;
+ this._modelMeshes = [];
+ this._rightArmMeshes = [];
+ this._r15Skeleton = null;
+ this._r15Animator = null;
+ this._isR15 = false;
+ this._modelKind = 'r15';
+ this._modelHipHeight = null;
+ this._nonHumanoidBox = null;
+ // 2) Сбросить AABB к дефолтному «человеку» (non-humanoid его переопределит).
+ this.HALF_W = 0.3;
+ this.HALF_H = 0.9;
+ this.HALF_D = 0.3;
+ this.HALF_H_NORMAL = 0.9;
+ this.EYE_HEIGHT = 0.7;
+ // 3) Поднять игрока на запас, чтобы новый AABB не оказался в полу.
+ this._pos.y += 0.5;
+ // 4) Загрузить новую модель.
+ this._modelTypeId = newType;
+ await this._loadPlayerModel();
+ return !!this._modelRoot;
+ }
+
+ /**
+ * Запустить режим игры.
+ * spawnPos — точка спавна. Если не указано — (0, 5, 0).
+ */
+ async start(spawnPos = null) {
+ if (this._active) return;
+ this._active = true;
+
+ if (spawnPos) {
+ this._pos = new Vector3(spawnPos.x, spawnPos.y + this.HALF_H, spawnPos.z);
+ } else {
+ this._pos = new Vector3(0, 5 + this.HALF_H, 0);
+ }
+ this._vy = 0;
+ this._yaw = 0;
+ this._pitch = 0;
+ this._modelYaw = 0;
+ this._codes.clear();
+ this._shift = false;
+
+ // FPS-камера
+ const cam = new UniversalCamera('playerCamera', new Vector3(0, 0, 0), this.scene);
+ cam.minZ = 0.1;
+ cam.maxZ = 1000;
+ cam.fov = 1.05;
+ cam.inputs.clear();
+ this.scene.activeCamera = cam;
+ this.camera = cam;
+
+ this._setupInput();
+
+ // Грузим модель персонажа. Ждём — иначе игрок секунду-две стоит
+ // без меша (или появляется частично), а движение/колайдер уже
+ // активны. start() теперь async-функция — все её вызовы (`await`).
+ await this._loadPlayerModel();
+
+ // Render-loop hook
+ this._beforeRender = () => this._tick();
+ this.scene.registerBeforeRender(this._beforeRender);
+
+ // Pointer-lock запрашиваем ТОЛЬКО для режимов где он нужен сразу:
+ // - first / lockfirst — постоянный lock
+ // - sideview (GD) — раньше тоже лочил, оставляем для авто-управления
+ // Для third — НЕ лочим (Roblox-style: курсор виден, ПКМ = orbit).
+ // ШС-lock (_shiftLock) обрабатывается отдельно через keydown 'L'.
+ const needLockAtStart = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (needLockAtStart) {
+ this._requestPointerLockSafe();
+ }
+ // Применяем видимость курсора (по умолчанию виден в third).
+ this._applyCursorVisibility();
+ }
+
+ /**
+ * Установить курсор видимым/скрытым через CSS на canvas.
+ * Pointer-lock сам прячет курсор когда активен, но в third без lock
+ * мы можем скрыть курсор через `cursor:none` если разработчик
+ * выключил его через setMouseIconVisible(false).
+ */
+ _applyCursorVisibility() {
+ if (!this.canvas) return;
+ const locked = (document.pointerLockElement === this.canvas);
+ // Если lock активен — курсор и так скрыт. Иначе зависит от настроек.
+ if (locked) return;
+ const show = this._mouseIconVisible && !this._shiftLock;
+ this.canvas.style.cursor = show ? '' : 'none';
+ }
+
+ /**
+ * Безопасный запрос Pointer Lock с обработкой SecurityError при быстрых
+ * Play→Stop→Play подряд. Если предыдущий lock не отпущен — ждём
+ * pointerlockchange и пробуем снова один раз.
+ */
+ /**
+ * Включить/выключить «UI-режим курсора».
+ * В этом режиме мышь свободна (можно кликать по GUI), камера не вращается.
+ * Чтобы вернуться к управлению камерой — снова setUiCursorMode(false).
+ */
+ /** Колбэк изменения HP — ({hp, maxHp}). */
+ setOnHpChange(cb) { this._onHpChange = cb; }
+ setOnDeath(cb) { this._onDeath = cb; }
+
+ /** Нанести урон игроку (с учётом i-frames). */
+ takeDamage(amount, source) {
+ if (this.hp <= 0) return;
+ const now = performance.now() / 1000;
+ if (now - this._lastDamageTime < this._invulnerabilityTime) return;
+ this._lastDamageTime = now;
+ this.hp = Math.max(0, this.hp - Math.max(0, amount));
+ // Flash-эффект для UI (через onHpChange флаг damaged=true)
+ if (this._onHpChange) {
+ try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp, source, damaged: true }); } catch (e) {}
+ }
+ // Звук «ой»
+ this._playHurtSound();
+ if (this.hp === 0) {
+ // Эффект распада
+ this._spawnDeathDebris();
+ // Прячем модель игрока
+ if (this._modelRoot) this._modelRoot.setEnabled(false);
+ if (this._onDeath) {
+ try { this._onDeath(); } catch (e) {}
+ }
+ }
+ }
+
+ /** Распад на куски при смерти. */
+ _spawnDeathDebris() {
+ if (!this._pos) return;
+ const cx = this._pos.x, cy = this._pos.y, cz = this._pos.z;
+ const colors = [
+ new Color3(0.95, 0.78, 0.6), // кожа
+ new Color3(0.7, 0.5, 0.4),
+ new Color3(0.4, 0.4, 0.7), // одежда
+ new Color3(0.3, 0.25, 0.2),
+ ];
+ for (let i = 0; i < 10; i++) {
+ const size = 0.18 + Math.random() * 0.14;
+ const cube = MeshBuilder.CreateBox(`pdebris_${i}`, { size }, this.scene);
+ const mat = new StandardMaterial(`pdebrisMat_${i}`, this.scene);
+ mat.diffuseColor = colors[i % colors.length];
+ mat.specularColor = new Color3(0, 0, 0);
+ cube.material = mat;
+ cube.position.set(
+ cx + (Math.random() - 0.5) * 0.5,
+ cy + Math.random() * 0.6,
+ cz + (Math.random() - 0.5) * 0.5
+ );
+ cube.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
+ cube.isPickable = false;
+ cube.alwaysSelectAsActiveMesh = true;
+ const debris = {
+ mesh: cube, mat,
+ vx: (Math.random() - 0.5) * 5,
+ vy: 4 + Math.random() * 3,
+ vz: (Math.random() - 0.5) * 5,
+ rx: (Math.random() - 0.5) * 10,
+ ry: (Math.random() - 0.5) * 10,
+ rz: (Math.random() - 0.5) * 10,
+ age: 0,
+ life: 2.0,
+ };
+ if (!this._debris) this._debris = [];
+ this._debris.push(debris);
+ }
+ }
+
+ /** Тик debris — вызывается в _tick. */
+ _tickDebris(dt) {
+ if (!this._debris || this._debris.length === 0) return;
+ const G = -10;
+ const next = [];
+ for (const d of this._debris) {
+ d.age += dt;
+ if (d.age >= d.life) {
+ try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
+ continue;
+ }
+ d.vy += G * dt;
+ d.mesh.position.x += d.vx * dt;
+ d.mesh.position.y += d.vy * dt;
+ d.mesh.position.z += d.vz * dt;
+ if (d.mesh.position.y < 0.1) {
+ d.mesh.position.y = 0.1;
+ d.vy *= -0.4;
+ d.vx *= 0.6;
+ d.vz *= 0.6;
+ }
+ d.mesh.rotation.x += d.rx * dt;
+ d.mesh.rotation.y += d.ry * dt;
+ d.mesh.rotation.z += d.rz * dt;
+ const fadeStart = d.life - 0.5;
+ if (d.age > fadeStart) {
+ const k = 1 - (d.age - fadeStart) / 0.5;
+ d.mesh.visibility = Math.max(0, k);
+ }
+ next.push(d);
+ }
+ this._debris = next;
+ }
+
+ /** Короткий звук «ой» когда получили урон. */
+ _playHurtSound() {
+ try {
+ if (!this._audioCtx) {
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ this._audioCtx = new Ctx();
+ }
+ const ctx = this._audioCtx;
+ if (ctx.state === 'suspended') ctx.resume();
+ const t = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(220, t);
+ osc.frequency.exponentialRampToValueAtTime(80, t + 0.15);
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(0.15, t);
+ g.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
+ osc.connect(g).connect(ctx.destination);
+ osc.start(t);
+ osc.stop(t + 0.22);
+ } catch (e) { /* ignore */ }
+ }
+
+ /** Полное восстановление HP (например при респавне). */
+ healFull() {
+ this.hp = this.maxHp;
+ this._lastDamageTime = performance.now() / 1000; // i-frames на момент респавна
+ // Возвращаем модель
+ if (this._modelRoot) this._modelRoot.setEnabled(true);
+ // Сбрасываем оставшиеся debris
+ if (this._debris) {
+ for (const d of this._debris) {
+ try { d.mesh.dispose(); d.mat.dispose(); } catch (e) {}
+ }
+ this._debris = [];
+ }
+ if (this._onHpChange) {
+ try { this._onHpChange({ hp: this.hp, maxHp: this.maxHp }); } catch (e) {}
+ }
+ }
+
+ setUiCursorMode(enabled) {
+ this._uiCursorMode = !!enabled;
+ if (enabled) {
+ // Освобождаем мышь
+ if (document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) { /* ignore */ }
+ }
+ } else {
+ // Возвращаем lock — но только если мы реально активны
+ if (this._active) {
+ this._requestPointerLockSafe();
+ }
+ }
+ }
+ isUiCursorMode() { return !!this._uiCursorMode; }
+
+ /**
+ * Callback, который вызывается при движении мыши в UI-режиме.
+ * fn(x, y) — нормализованные координаты [0..1] относительно канваса.
+ * Используется для drag-механик (Дальгона и т.д.).
+ */
+ setUiMouseMoveCallback(fn) {
+ this._uiMouseMoveCb = (typeof fn === 'function') ? fn : null;
+ }
+ /** mousedown в UI-режиме. fn(x, y). */
+ setUiMouseDownCallback(fn) {
+ this._uiMouseDownCb = (typeof fn === 'function') ? fn : null;
+ }
+ /** mouseup в UI-режиме. fn(x, y). */
+ setUiMouseUpCallback(fn) {
+ this._uiMouseUpCb = (typeof fn === 'function') ? fn : null;
+ }
+
+ _requestPointerLockSafe(retried = false) {
+ if (!this._active || !this.canvas?.requestPointerLock) return;
+ // На тач-устройствах pointer-lock не нужен — управление через touch-overlay
+ if (this._touchMode) return;
+ // UI-режим (скрипт включил курсор через game.input.setCursorMode('ui'))
+ // — не захватываем мышь.
+ if (this._uiCursorMode) return;
+ // Если уже есть lock на этот canvas — нечего делать
+ if (document.pointerLockElement === this.canvas) return;
+ // Если есть lock на ДРУГОМ элементе — ждём pointerlockchange и пробуем
+ if (document.pointerLockElement && document.pointerLockElement !== this.canvas) {
+ if (retried) return; // только одна попытка повтора
+ const onChange = () => {
+ document.removeEventListener('pointerlockchange', onChange);
+ if (this._active) this._requestPointerLockSafe(true);
+ };
+ document.addEventListener('pointerlockchange', onChange, { once: true });
+ return;
+ }
+ requestAnimationFrame(() => {
+ if (!this._active) return;
+ try {
+ const p = this.canvas.requestPointerLock();
+ // Promise-форма: ловим reject (SecurityError) и пробуем повтор
+ if (p && typeof p.catch === 'function') {
+ p.catch((err) => {
+ if (!this._active) return;
+ // SecurityError — попробуем ещё раз через кадр (один раз)
+ if (!retried && err && err.name === 'SecurityError') {
+ setTimeout(() => this._requestPointerLockSafe(true), 50);
+ }
+ });
+ }
+ } catch (e) { /* legacy form, ignore */ }
+ });
+ }
+
+ /**
+ * Загрузить манифест R15-скинов (характеристики + overrides).
+ * Кешируется в this._skinManifest. Возвращает массив skins или [].
+ */
+ async _loadSkinManifest() {
+ if (this._skinManifest) return this._skinManifest;
+ try {
+ const resp = await fetch('/kubikon-assets/characters/skins_manifest.json');
+ const json = await resp.json();
+ this._skinManifest = json.skins || [];
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[PlayerController] skins_manifest load failed:', e);
+ this._skinManifest = [];
+ }
+ return this._skinManifest;
+ }
+
+ /**
+ * Определить путь к GLB и overrides для текущего _modelTypeId.
+ * - 'skin_*' → R15-скин из characters//body.glb + overrides из манифеста
+ * - иначе → старая Kenney-модель через getModelType()
+ * Возвращает { file, isR15, overrides } или null.
+ */
+ async _resolveModelSource() {
+ const typeId = this._modelTypeId || 'character-a';
+ if (typeId.startsWith('skin_')) {
+ const manifest = await this._loadSkinManifest();
+ const entry = manifest.find((s) => s.id === typeId);
+ if (entry) {
+ // kind определяет систему анимации:
+ // 'r15' → R15-скелет (как раньше)
+ // 'non-humanoid-mesh' → single-mesh, процедурное покачивание
+ // 'non-humanoid-rigged' → свой скелет + встроенные AnimationGroup
+ // Отсутствие kind = 'r15' (обратная совместимость со старым манифестом).
+ const kind = entry.kind || 'r15';
+ return {
+ file: '/kubikon-assets/' + entry.file,
+ isR15: kind === 'r15',
+ kind,
+ overrides: entry.overrides || {},
+ scaleManifest: Number.isFinite(entry.scale) ? entry.scale : null,
+ hipHeight: Number.isFinite(entry.hipHeight) ? entry.hipHeight : null,
+ rotationYOffset: Number.isFinite(entry.rotationYOffset) ? entry.rotationYOffset : 0,
+ };
+ }
+ // нет в манифесте — пробуем прямой путь (старые R15-скины)
+ return {
+ file: `/kubikon-assets/characters/${typeId}/body.glb`,
+ isR15: true,
+ kind: 'r15',
+ overrides: {},
+ };
+ }
+ // Кастомный .glb пользователя: 'customskin:'. dataUrl + метаданные
+ // (scale/hipHeight) лежат в scene._skinsConfig.customGlbs.
+ if (typeId.startsWith('customskin:')) {
+ const slug = typeId.slice('customskin:'.length);
+ const list = this._scene3d?._skinsConfig?.customGlbs || [];
+ const meta = list.find(g => g && g.slug === slug) || null;
+ const url = this._scene3d?.getAssetDataUrl?.(slug) || (meta && meta.dataUrl) || null;
+ if (url) {
+ return {
+ file: url, isR15: false, kind: 'non-humanoid-mesh', overrides: {},
+ scaleManifest: meta?.scale ?? 1.5,
+ hipHeight: meta?.hipHeight ?? 0.4,
+ rotationYOffset: meta?.rotationYOffset ?? 0,
+ isDataUrl: true,
+ };
+ }
+ return null;
+ }
+ const modelType = getModelType(typeId);
+ if (!modelType) return null;
+ return { file: modelType.file, isR15: false, kind: 'r15', overrides: {} };
+ }
+
+ /** Загрузить GLB-модель персонажа и его анимации. */
+ async _loadPlayerModel() {
+ const source = await this._resolveModelSource();
+ if (!source) return;
+ if (!this._active) return;
+
+ // ВАЖНО: грузим через SceneLoader напрямую, НЕ через shared-кэш
+ // ModelManager. Если бы мы использовали тот же AssetContainer
+ // что и зомби (через _loadPrototype), повторный
+ // instantiateModelsToScene давал меши с битыми материалами.
+ // Babylon HTTP-кэш всё равно убирает сетевые запросы.
+ let rootUrl, filename;
+ if (source.isDataUrl) {
+ // Кастомный скин — data:URL. SceneLoader принимает его как rootUrl=''
+ // и filename=data:... с подсказкой расширения через ?name=.
+ rootUrl = '';
+ filename = source.file;
+ } else {
+ const lastSlash = source.file.lastIndexOf('/');
+ rootUrl = source.file.substring(0, lastSlash + 1);
+ filename = source.file.substring(lastSlash + 1);
+ }
+ let container;
+ try {
+ container = await SceneLoader.LoadAssetContainerAsync(
+ rootUrl, filename, this.scene,
+ null, source.isDataUrl ? '.glb' : undefined
+ );
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[PlayerController] failed to load model:', e);
+ return;
+ }
+ try {
+ if (!this._active) {
+ try { container.dispose(); } catch (e) {}
+ return;
+ }
+ // Создаём корневой узел и инстанцируем модель туда
+ const root = new TransformNode('playerModel', this.scene);
+ // Масштаб модели — рост ~2 блока (1.8 м, как AABB игрока).
+ // - R15-скины ('skin_*'): фиксированный 0.301 — модели
+ // нормализованы к 5.98 ед пайплайном auto_rig_bacon
+ // (1.8 / 5.98 ≈ 0.301). AABB-based scale ломается на скинах
+ // с торчащими волосами/плащами (как у bacon-hair).
+ // - Kenney-модели: старый 0.72.
+ // - overrides.scale_mult — per-skin множитель из манифеста.
+ const isNonHumanoid = source.kind === 'non-humanoid-mesh'
+ || source.kind === 'non-humanoid-rigged';
+ let modelScale;
+ if (isNonHumanoid) {
+ // Non-humanoid: базовый размер берём из манифеста (scale), а если
+ // нет — нормализуем по bounding box к ~1.6 ед высоты (как игрок).
+ modelScale = Number.isFinite(source.scaleManifest) ? source.scaleManifest : 1.0;
+ } else {
+ modelScale = source.isR15 ? 0.301 : this._modelScale;
+ const scaleMult = (source.overrides && source.overrides.scale_mult) || 1.0;
+ modelScale *= scaleMult;
+ }
+ root.scaling = new Vector3(modelScale, modelScale, modelScale);
+ if (source.rotationYOffset) root.rotation.y = source.rotationYOffset;
+ const inst = container.instantiateModelsToScene(
+ (name) => `player_${name}`,
+ /*cloneAnimations*/ true,
+ { doNotInstantiate: false }
+ );
+ for (const r of inst.rootNodes) {
+ r.parent = root;
+ }
+ this._modelRoot = root;
+ this._modelKind = source.kind || 'r15';
+ // hipHeight: на сколько центр модели поднят от «низа ног».
+ // Используется и для позиционирования модели, и для камеры/AABB.
+ this._modelHipHeight = Number.isFinite(source.hipHeight) ? source.hipHeight : null;
+
+ // Non-humanoid: нормализуем размер и опускаем модель на «ноги».
+ if (isNonHumanoid) {
+ this._setupNonHumanoidModel(root, modelScale, source);
+ }
+
+ // === R15-скин: детекция скелета ===
+ // R15-скины приходят с встроенным скелетом Mixamo. Babylon
+ // распарсил его в inst.skeletons. Создаём R15Skeleton-резолвер
+ // и, если скелет валидный, помечаем _isR15 + создаём аниматор.
+ this._isR15 = false;
+ this._r15Skeleton = null;
+ this._r15Animator = null;
+ this._skinOverrides = source.overrides || {};
+ // eslint-disable-next-line no-console
+ console.log('[PlayerController] _loadPlayerModel: file=' + source.file
+ + ' isR15=' + source.isR15
+ + ' inst.skeletons=' + ((inst.skeletons || []).length)
+ + ' rootNodes=' + (inst.rootNodes || []).length);
+ if (source.isR15) {
+ // Скелет ищем в нескольких местах: inst.skeletons (норма),
+ // container.skeletons (иногда не клонируется), на мешах
+ // модели (skeleton-property). Берём первый найденный.
+ let sk = (inst.skeletons && inst.skeletons[0]) || null;
+ if (!sk && container.skeletons && container.skeletons.length > 0) {
+ sk = container.skeletons[0];
+ }
+ if (!sk) {
+ const meshWithSkel = root.getChildMeshes(false).find((m) => m.skeleton);
+ if (meshWithSkel) sk = meshWithSkel.skeleton;
+ }
+ if (sk) {
+ // eslint-disable-next-line no-console
+ console.log('[PlayerController] скелет найден: bones=' + (sk.bones || []).length
+ + ' имена=' + (sk.bones || []).slice(0, 24).map(b => b.name).join(','));
+ const r15 = new R15Skeleton(sk);
+ if (r15.isValidR15()) {
+ this._r15Skeleton = r15;
+ this._isR15 = true;
+ this._r15Animator = new R15Animator(r15, this._skinOverrides);
+ // eslint-disable-next-line no-console
+ console.log('[PlayerController] R15-скин загружен:',
+ this._modelTypeId, '— костей:', r15.resolvedNames().length,
+ 'overrides:', JSON.stringify(this._skinOverrides));
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn('[PlayerController] R15-скин', this._modelTypeId,
+ '— скелет не прошёл валидацию. Зарезолвлено:',
+ r15.resolvedNames().join(','),
+ '| все кости скелета:',
+ (sk.bones || []).map(b => b.name).join(','));
+ }
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn('[PlayerController] R15-скин', this._modelTypeId,
+ '— нет скелета в glb');
+ }
+ }
+
+ // Собираем все mesh-чилдрены (для toggle visibility в 1st person)
+ this._modelMeshes = root.getChildMeshes(false);
+ // Игрок не должен ловить свой raycast → отключаем pickable
+ for (const m of this._modelMeshes) {
+ m.isPickable = false;
+ if (m.alwaysSelectAsActiveMesh !== undefined) {
+ m.alwaysSelectAsActiveMesh = true;
+ }
+ // Тени: персонаж принимает тени от мира и сам отбрасывает.
+ m.receiveShadows = true;
+ }
+ try {
+ if (this._scene3d && typeof this._scene3d.addShadowCaster === 'function') {
+ for (const m of this._modelMeshes) {
+ this._scene3d.addShadowCaster(m);
+ }
+ }
+ } catch (e) { /* ignore */ }
+ // У Kenney character имена `arm-left`/`arm-right` соответствуют
+ // СОБСТВЕННОЙ стороне персонажа (его правая = arm-right).
+ // Когда мы смотрим персонажу В ЛИЦО (3-rd person сзади) — его
+ // правая рука у нас слева на экране.
+ //
+ // Берём именно arm-right (его правую) — это та рука куда логично
+ // вкладывать оружие. По логу: arm-right @ x=-0.4, arm-left @ x=+0.4.
+ this._rightArmMeshes = [];
+ for (const m of this._modelMeshes) {
+ const n = (m.name || '').toLowerCase();
+ // Берём именно «right» — настоящая правая рука персонажа
+ if (n === 'player_arm-right' || n.endsWith('arm-right')
+ || n.includes('right-arm') || n.includes('rightarm')
+ || n.includes('right-hand') || n.includes('hand-right')) {
+ this._rightArmMeshes.push(m);
+ break;
+ }
+ }
+ // Fallback: если по имени не нашли — берём левую по позиции (x<0)
+ if (this._rightArmMeshes.length === 0) {
+ let bestMesh = null;
+ let bestX = Infinity;
+ for (const m of this._modelMeshes) {
+ if (!m.position) continue;
+ const n = (m.name || '').toLowerCase();
+ if (n.includes('leg') || n.includes('foot') || n.includes('head')
+ || n.includes('hat') || n.includes('torso') || n.includes('root')) continue;
+ const px = m.position.x;
+ const py = m.position.y;
+ if (py < 0.8 || py > 2.2) continue;
+ if (px >= -0.05) continue; // ищем X<0
+ if (px < bestX) { bestX = px; bestMesh = m; }
+ }
+ if (bestMesh) this._rightArmMeshes = [bestMesh];
+ }
+ const arm = this._rightArmMeshes[0];
+ if (arm) {
+ this._rightArmX = arm.position.x;
+ this._rightArmY = arm.position.y;
+ this._rightArmZ = arm.position.z;
+ }
+
+ // Анимации.
+ // R15-скины не содержат AnimationGroups (анимируются процедурно
+ // через R15Animator в _tick). Kenney-модели — наоборот, имеют
+ // встроенные AnimationGroups (idle/walk/sprint/jump).
+ this._animations = {};
+ if (!this._isR15) {
+ const groups = inst.animationGroups || [];
+ for (const g of groups) {
+ const name = (g.name || '').toLowerCase();
+ if (name.includes('idle')) this._animations.idle = g;
+ else if (name.includes('sprint') || name.includes('run')) this._animations.sprint = g;
+ else if (name.includes('walk')) this._animations.walk = g;
+ else if (name.includes('jump')) this._animations.jump = g;
+ g.stop();
+ }
+ this._playAnim('idle');
+ }
+ // Применяем текущий camera-mode (показать/скрыть модель)
+ this._applyCameraMode();
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[PlayerController] failed to load model:', e);
+ }
+ }
+
+ /**
+ * Настройка non-humanoid модели (животное/машина/еда): нормализация
+ * размера и опускание на «низ ног». В отличие от R15 (нормализованы
+ * пайплайном), эти модели произвольного размера, поэтому считаем bbox.
+ *
+ * Локальные координаты root: модель должна стоять так, чтобы её низ был
+ * на y=0 (там «ноги»). PlayerController позиционирует root в точке
+ * `_pos.y - HALF_H` (низ AABB) — модель «садится» на землю.
+ */
+ _setupNonHumanoidModel(root, scaleApplied, source) {
+ try {
+ // Считаем bounding box всех дочерних мешей в world-координатах ПОСЛЕ
+ // применения scaling root'а. Babylon refreshBoundingInfo нужен после
+ // инстансинга.
+ const meshes = root.getChildMeshes(false).filter(m => m.getBoundingInfo && m.getTotalVertices && m.getTotalVertices() > 0);
+ if (!meshes.length) return;
+ root.computeWorldMatrix(true);
+ let minY = Infinity, maxY = -Infinity, maxDim = 0;
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
+ for (const m of meshes) {
+ m.computeWorldMatrix(true);
+ // refreshBoundingInfo(true) — пересчитать bbox с учётом возможного
+ // скелета/морфов; без него minimumWorld у инстансов часто нулевой
+ // или из исходной позы → центр считался неверно (баг пришельца/робота).
+ try { m.refreshBoundingInfo(true); } catch (e) { try { m.refreshBoundingInfo(); } catch (e2) {} }
+ const bi = m.getBoundingInfo();
+ const bb = bi.boundingBox;
+ const lo = bb.minimumWorld, hi = bb.maximumWorld;
+ if (!lo || !hi) continue;
+ minY = Math.min(minY, lo.y); maxY = Math.max(maxY, hi.y);
+ minX = Math.min(minX, lo.x); maxX = Math.max(maxX, hi.x);
+ minZ = Math.min(minZ, lo.z); maxZ = Math.max(maxZ, hi.z);
+ }
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
+ const h = maxY - minY;
+ const w = maxX - minX;
+ const d = maxZ - minZ;
+ maxDim = Math.max(h, w, d);
+ // === Центрирование модели через pivot-node ===
+ // Многие Kenney-модели имеют origin НЕ в геометрическом центре
+ // (в углу/ноге) → при повороте модель «облетает» вокруг смещённого
+ // origin (баг пришельца/робота). Ручной сдвиг детей с делением на
+ // scaleApplied неверен если у детей свой scale/rotation. Надёжно:
+ // вставляем промежуточный pivot между root и моделью и смещаем pivot
+ // на -localCenter (через инверсию world-матрицы root — точно при
+ // любом scale/rotation).
+ const worldCenter = new Vector3(
+ (minX + maxX) / 2, // центр X
+ minY, // низ Y (модель «садится» на ноги)
+ (minZ + maxZ) / 2 // центр Z
+ );
+ // world-центр → локальные координаты root
+ const invRoot = root.getWorldMatrix().clone().invert();
+ const localCenter = Vector3.TransformCoordinates(worldCenter, invRoot);
+ const pivot = new TransformNode('playerModelPivot', this.scene);
+ pivot.parent = root;
+ pivot.position.set(-localCenter.x, -localCenter.y, -localCenter.z);
+ // Перевешиваем все прямые дочерние ноды root (кроме самого pivot) на pivot.
+ for (const ch of root.getChildren().slice()) {
+ if (ch === pivot) continue;
+ ch.parent = pivot;
+ }
+ // Сохраняем размеры для настраиваемого AABB и камеры.
+ // hipHeight из манифеста — приоритетно; иначе берём низ модели.
+ this._nonHumanoidBox = { w, h, d };
+ this._modelBaseHeight = h;
+ // AABB подгоняем под модель (плоская/широкая для машин, узкая для еды).
+ // Ограничиваем разумными пределами чтобы не проваливаться/застревать.
+ this.HALF_W = Math.max(0.25, Math.min(0.9, w / 2));
+ this.HALF_D = Math.max(0.25, Math.min(1.1, d / 2));
+ const halfH = Math.max(0.3, Math.min(1.0, h / 2));
+ this.HALF_H = halfH;
+ this.HALF_H_NORMAL = halfH;
+ this.EYE_HEIGHT = halfH * 0.7;
+ // eslint-disable-next-line no-console
+ console.log('[PlayerController] non-humanoid setup:', this._modelTypeId,
+ 'kind=' + source.kind, 'bbox(w/h/d)=' + w.toFixed(2) + '/' + h.toFixed(2) + '/' + d.toFixed(2),
+ 'AABB(W/H/D)=' + this.HALF_W.toFixed(2) + '/' + this.HALF_H.toFixed(2) + '/' + this.HALF_D.toFixed(2));
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[PlayerController] _setupNonHumanoidModel failed:', e);
+ }
+ }
+
+ /**
+ * Процедурная анимация single-mesh скина (нет скелета — нечего анимировать
+ * костями). Лёгкое покачивание вверх-вниз при движении + наклон вперёд при
+ * беге + наклон в воздухе. Вызывается каждый кадр из _tick.
+ * baseY — локальная база модели (опущена на ноги в _setupNonHumanoidModel).
+ */
+ _animateNonHumanoidMesh(dt) {
+ const root = this._modelRoot;
+ if (!root) return;
+ const t = (typeof performance !== 'undefined' && performance.now)
+ ? performance.now() / 1000 : Date.now() / 1000;
+ const speed = this._lastFrameSpeed || 0;
+ // Базовое вращение по yaw уже выставляет _tick (он крутит модель под
+ // направление движения/камеры). Мы добавляем ТОЛЬКО процедурный bob/tilt
+ // поверх — храним их в отдельных полях, чтобы _tick их не перетёр.
+ let bobY = 0, tiltX = 0;
+ if (!this._isGrounded) {
+ tiltX = 0.2; // в воздухе — нос вверх
+ } else if (speed > 0.1) {
+ const bobFreq = 8 * Math.min(2, speed / 4);
+ bobY = Math.sin(t * bobFreq) * 0.06;
+ tiltX = Math.min(speed * 0.04, 0.13);
+ } else {
+ bobY = Math.sin(t * 1.5) * 0.015; // лёгкое «дыхание» в покое
+ }
+ // Применяем поверх позиции, которую _tick уже выставил в root.position.y.
+ root.position.y += bobY;
+ // tilt по локальной оси X модели. rotation.y уже выставлен _tick'ом.
+ root.rotation.x = tiltX;
+ }
+
+ /** AABB игрока пересекает хотя бы один блок-воду. */
+ _isInWater() {
+ const bm = this._scene3d?.blockManager;
+ if (!bm) return false;
+ // FAST PATH: если на сцене нет водных блоков — точно не в воде.
+ // Большинство карт (зомби-остров, любые «суховые») — без воды,
+ // и тройной цикл ниже бесполезно тратит время каждый кадр.
+ if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
+ const px = this._pos.x, py = this._pos.y, pz = this._pos.z;
+ const hw = this.HALF_W, hh = this.HALF_H, hd = this.HALF_D;
+ // Проверяем клетки которые AABB перекрывает
+ const gxMin = Math.floor(px - hw + 0.5);
+ const gxMax = Math.floor(px + hw + 0.5);
+ const gzMin = Math.floor(pz - hd + 0.5);
+ const gzMax = Math.floor(pz + hd + 0.5);
+ const gyMin = Math.floor(py - hh);
+ const gyMax = Math.floor(py + hh);
+ for (let gx = gxMin; gx <= gxMax; gx++) {
+ for (let gy = gyMin; gy <= gyMax; gy++) {
+ for (let gz = gzMin; gz <= gzMax; gz++) {
+ const m = bm.blocks.get(`${gx},${gy},${gz}`);
+ if (m?.metadata?.isWater) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** AABB игрока ПОЛНОСТЬЮ внутри блоков-воды (голова под водой). */
+ _isSubmerged() {
+ const bm = this._scene3d?.blockManager;
+ if (!bm) return false;
+ // FAST PATH: нет воды на сцене — не утопаем.
+ if (!bm._waterMeshes || bm._waterMeshes.size === 0) return false;
+ // Проверяем «голову» — точку чуть ниже верха AABB
+ const headY = this._pos.y + this.HALF_H - 0.1;
+ const gx = Math.round(this._pos.x);
+ const gy = Math.floor(headY);
+ const gz = Math.round(this._pos.z);
+ const m = bm.blocks.get(`${gx},${gy},${gz}`);
+ return !!m?.metadata?.isWater;
+ }
+
+ /** Воспроизвести звук шага. Создаёт короткий burst через Web Audio. */
+ _playFootstep() {
+ try {
+ if (!this._audioCtx) {
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ this._audioCtx = new Ctx();
+ }
+ const ctx = this._audioCtx;
+ if (ctx.state === 'suspended') ctx.resume();
+
+ const now = ctx.currentTime;
+ const duration = 0.08;
+
+ // Источник — короткий шумовой буфер
+ const sampleRate = ctx.sampleRate;
+ const length = Math.floor(sampleRate * duration);
+ const buffer = ctx.createBuffer(1, length, sampleRate);
+ const data = buffer.getChannelData(0);
+ for (let i = 0; i < length; i++) {
+ data[i] = (Math.random() * 2 - 1) * (1 - i / length); // затухающий шум
+ }
+ const src = ctx.createBufferSource();
+ src.buffer = buffer;
+
+ // Lowpass для тяжёлого «тук» вместо высокого «шшш»
+ const lowpass = ctx.createBiquadFilter();
+ lowpass.type = 'lowpass';
+ lowpass.frequency.value = 350;
+ lowpass.Q.value = 1.5;
+
+ // Envelope (быстрая атака, быстрое затухание)
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(0, now);
+ gain.gain.linearRampToValueAtTime(0.7, now + 0.005);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ src.connect(lowpass).connect(gain).connect(ctx.destination);
+ src.start(now);
+ src.stop(now + duration);
+ } catch (e) {
+ // ignore — звук не критичен
+ }
+ }
+
+ /**
+ * Звук прыжка — мягкий «boing» из двух слоёв:
+ * 1) низкий thump (sine 90Hz, очень короткий) — «толчок ногами»
+ * 2) высокий pitch-down sine (700→500 Hz) — «лёгкость подъёма»
+ * Гораздо приятнее старого квадратного восходящего тона.
+ */
+ /**
+ * Проиграть эмоцию персонажа (wave/dance/cheer/sit) — game.player.playAnimation.
+ * Работает только для R15-скинов (Kenney-модели эмоций не имеют).
+ */
+ playEmote(name) {
+ if (this._isR15 && this._r15Animator) {
+ return this._r15Animator.playEmote(name);
+ }
+ return false;
+ }
+
+ /** Прервать текущую эмоцию персонажа. */
+ stopEmote() {
+ if (this._isR15 && this._r15Animator) this._r15Animator.stopEmote();
+ }
+
+ _playJumpSound() {
+ // Хук для скриптов: game.onPlayerJump. Вызывается на каждый прыжок
+ // (обычный / UFO / двойной) — _playJumpSound гарантированно зовётся.
+ if (typeof this._onJump === 'function') {
+ try { this._onJump(); } catch (e) { /* ignore */ }
+ }
+ try {
+ if (!this._audioCtx) {
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ this._audioCtx = new Ctx();
+ }
+ const ctx = this._audioCtx;
+ if (ctx.state === 'suspended') ctx.resume();
+ const now = ctx.currentTime;
+ const out = ctx.destination;
+
+ // Слой 1: низкий thump
+ const thumpDur = 0.07;
+ const thumpOsc = ctx.createOscillator();
+ thumpOsc.type = 'sine';
+ thumpOsc.frequency.setValueAtTime(110, now);
+ thumpOsc.frequency.exponentialRampToValueAtTime(60, now + thumpDur);
+ const thumpGain = ctx.createGain();
+ thumpGain.gain.setValueAtTime(0, now);
+ thumpGain.gain.linearRampToValueAtTime(0.35, now + 0.005);
+ thumpGain.gain.exponentialRampToValueAtTime(0.001, now + thumpDur);
+ thumpOsc.connect(thumpGain).connect(out);
+ thumpOsc.start(now);
+ thumpOsc.stop(now + thumpDur + 0.02);
+
+ // Слой 2: «boing» — pitch-down sine
+ const boingDur = 0.18;
+ const boingOsc = ctx.createOscillator();
+ boingOsc.type = 'sine';
+ boingOsc.frequency.setValueAtTime(720, now + 0.005);
+ boingOsc.frequency.exponentialRampToValueAtTime(440, now + 0.005 + boingDur);
+ const boingGain = ctx.createGain();
+ boingGain.gain.setValueAtTime(0, now + 0.005);
+ boingGain.gain.linearRampToValueAtTime(0.18, now + 0.02);
+ boingGain.gain.exponentialRampToValueAtTime(0.001, now + 0.005 + boingDur);
+ // Лёгкое vibrato чтобы было «живее»
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.value = 14;
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.value = 18; // ±18 Hz
+ lfo.connect(lfoGain).connect(boingOsc.frequency);
+ boingOsc.connect(boingGain).connect(out);
+ boingOsc.start(now + 0.005);
+ lfo.start(now + 0.005);
+ boingOsc.stop(now + 0.005 + boingDur + 0.02);
+ lfo.stop(now + 0.005 + boingDur + 0.02);
+ } catch (e) { /* ignore */ }
+ }
+
+ /** Звук «бульк» при входе/выходе из воды. */
+ _playSplashSound() {
+ try {
+ if (!this._audioCtx) {
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ this._audioCtx = new Ctx();
+ }
+ const ctx = this._audioCtx;
+ if (ctx.state === 'suspended') ctx.resume();
+ const now = ctx.currentTime;
+ const duration = 0.35;
+
+ // Шум с быстро падающим highpass — звук «всплеска»
+ const length = Math.floor(ctx.sampleRate * duration);
+ const buf = ctx.createBuffer(1, length, ctx.sampleRate);
+ const data = buf.getChannelData(0);
+ for (let i = 0; i < length; i++) {
+ data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2;
+ }
+ const src = ctx.createBufferSource();
+ src.buffer = buf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(2000, now);
+ bp.frequency.exponentialRampToValueAtTime(400, now + duration);
+ bp.Q.value = 1.5;
+
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(0, now);
+ g.gain.linearRampToValueAtTime(0.6, now + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ src.connect(bp).connect(g).connect(ctx.destination);
+ src.start(now);
+ src.stop(now + duration);
+ } catch (e) { /* ignore */ }
+ }
+
+ _playAnim(name) {
+ if (this._currentAnim === name) return;
+ const target = this._animations[name];
+ if (!target) {
+ // если нужной нет — пробуем idle как fallback
+ if (name !== 'idle' && this._animations.idle) {
+ return this._playAnim('idle');
+ }
+ return;
+ }
+ // === ЛОГ ПЕРЕКЛЮЧЕНИЯ АНИМАЦИИ ===
+ // Помогает дебажить вибрацию ног на склонах: если в логе видно
+ // частое мерцание walk↔jump или idle↔walk — значит onGround или
+ // isMoving скачет каждые несколько кадров.
+ const dbg = this._lastAnimDebug || { vy: 0, og: false, sf: false, mv: false };
+ console.log(`[Anim] ${this._currentAnim || '∅'} → ${name} `
+ + `(onGround=${dbg.og}, surfFollow=${dbg.sf}, moving=${dbg.mv}, vy=${dbg.vy.toFixed(2)})`);
+ // Стопим текущую
+ if (this._currentAnim && this._animations[this._currentAnim]) {
+ this._animations[this._currentAnim].stop();
+ }
+ target.start(/*loop*/ true, /*speed*/ 1);
+ this._currentAnim = name;
+ }
+
+ /**
+ * Остановить режим игры. Освобождает все ресурсы.
+ */
+ stop() {
+ if (!this._active) return;
+ this._active = false;
+
+ if (this._beforeRender) {
+ this.scene.unregisterBeforeRender(this._beforeRender);
+ this._beforeRender = null;
+ }
+ for (const { target, type, fn, opts } of this._listeners) {
+ target.removeEventListener(type, fn, opts);
+ }
+ this._listeners = [];
+
+ if (document.pointerLockElement === this.canvas) {
+ document.exitPointerLock();
+ }
+
+ // Останавливаем все анимации
+ for (const g of Object.values(this._animations)) {
+ try { g.stop(); } catch (e) { /* ignore */ }
+ }
+ this._animations = {};
+ this._currentAnim = null;
+
+ // Удаляем якорь оружия
+ if (this._weaponAnchor) {
+ try { this._weaponAnchor.dispose(); } catch (e) { /* ignore */ }
+ this._weaponAnchor = null;
+ }
+ // Сбрасываем все override'ы вращения
+ if (this._meshRotationOverrides) {
+ this._meshRotationOverrides.clear();
+ }
+ // Сброс R15-состояния
+ if (this._r15BoneOverrides) this._r15BoneOverrides.clear();
+ this._r15Animator = null;
+ this._r15Skeleton = null;
+ this._isR15 = false;
+
+ // Удаляем модель
+ if (this._modelRoot) {
+ for (const m of this._modelMeshes) {
+ try { m.dispose(); } catch (e) { /* ignore */ }
+ }
+ try { this._modelRoot.dispose(); } catch (e) { /* ignore */ }
+ this._modelRoot = null;
+ this._modelMeshes = [];
+ }
+
+ if (this.camera) {
+ this.camera.dispose();
+ this.camera = null;
+ }
+
+ if (this._audioCtx) {
+ try { this._audioCtx.close(); } catch (e) { /* ignore */ }
+ this._audioCtx = null;
+ }
+ }
+
+ isActive() {
+ return this._active;
+ }
+
+ // === ВНУТРЕННЕЕ ===
+
+ /** Позиция камеры в мире (зависит от режима first/third/front). */
+ _computeCameraPos() {
+ // Виртуальная "визуальная" Y-позиция игрока — учитывает step-up
+ // оффсет. Физически игрок уже на pos.y, но мы плавно «догоняем»
+ // высоту чтобы камера не дёргалась рывком при step-up.
+ const visY = this._pos.y - this._stepUpVisualOffset;
+ if (this._cameraMode === 'first') {
+ return new Vector3(this._pos.x, visY + this.EYE_HEIGHT, this._pos.z);
+ }
+ if (this._cameraMode === 'sideview') {
+ // Кубикон Dash: камера сбоку, фиксированный yaw на куб.
+ // Игрок движется по +X, камера в -Z от него (смотрит на +Z).
+ // С этого ракурса +X на экране = вправо (как в Geometry Dash).
+ // Лёгкое смещение camera по X влево от куба — игрок в левой
+ // трети кадра, впереди видно больше уровня.
+ return new Vector3(
+ this._pos.x - 1.5,
+ visY + SIDEVIEW_HEIGHT,
+ this._pos.z - SIDEVIEW_DIST
+ );
+ }
+ // forward — направление «куда смотрит игрок» с учётом yaw и pitch
+ const cosP = Math.cos(this._pitch);
+ const fx = Math.sin(this._yaw) * cosP;
+ const fy = -Math.sin(this._pitch);
+ const fz = Math.cos(this._yaw) * cosP;
+ const dist = this._thirdDistance;
+
+ // Точка «глаз» игрока — отсюда пускаем луч к запланированной
+ // позиции камеры и сокращаем дистанцию если упёрлись в стену.
+ const eyeY = visY + this.EYE_HEIGHT + this.THIRD_HEIGHT_OFFSET;
+
+ if (this._cameraMode === 'third') {
+ const desired = new Vector3(
+ this._pos.x - fx * dist,
+ eyeY - fy * dist,
+ this._pos.z - fz * dist
+ );
+ return this._clampCameraToWorld(
+ this._pos.x, eyeY, this._pos.z, desired
+ );
+ }
+ // 'front' — спереди игрока, направлена назад (на лицо)
+ const desiredFront = new Vector3(
+ this._pos.x + fx * dist,
+ eyeY + fy * dist,
+ this._pos.z + fz * dist
+ );
+ return this._clampCameraToWorld(
+ this._pos.x, eyeY, this._pos.z, desiredFront
+ );
+ }
+
+ /**
+ * Не позволяет камере «проходить» сквозь стены/блоки/примитивы.
+ * Пускает луч от глаз игрока до запланированной позиции камеры.
+ * Если на пути есть препятствие — возвращает точку чуть ближе
+ * (hit.distance - PADDING), чтобы камера прижалась к стене.
+ *
+ * Игнорирует:
+ * - меши без metadata (вспомогательная техника редактора),
+ * - триггеры (canCollide===false),
+ * - саму модель игрока,
+ * - debris/particles.
+ */
+ _clampCameraToWorld(ex, ey, ez, desired) {
+ if (!this.scene) return desired;
+ // Вектор от глаз до желаемой камеры.
+ const dx = desired.x - ex;
+ const dy = desired.y - ey;
+ const dz = desired.z - ez;
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ if (len < 0.05) return desired; // камера почти в точке глаз — не уйдёт
+ const dir = new Vector3(dx / len, dy / len, dz / len);
+ const origin = new Vector3(ex, ey, ez);
+ const ray = new Ray(origin, dir, len);
+
+ const PADDING = 0.35; // отступ от стены, чтобы камера не «врезалась»
+ const playerRoot = this._modelRoot;
+
+ const pickPred = (mesh) => {
+ if (!mesh) return false;
+ if (!mesh.isEnabled || !mesh.isEnabled()) return false;
+ // Прозрачно-визуальные и technical meshes — пропускаем.
+ if (mesh.isPickable === false) return false;
+ const md = mesh.metadata || {};
+ // Триггеры/невидимки/скриптовые маркеры — не блокируют.
+ if (md.canCollide === false) return false;
+ if (md._isTriggerHelper) return false;
+ // Модель игрока (камера не должна цепляться за собственный меш).
+ if (playerRoot) {
+ let n = mesh;
+ while (n) {
+ if (n === playerRoot) return false;
+ n = n.parent;
+ }
+ }
+ // Жидкости (вода/лава) — не блокируют камеру.
+ if (md._liquidProxy) return false;
+ return true;
+ };
+
+ let hit = null;
+ try {
+ hit = this.scene.pickWithRay(ray, pickPred);
+ } catch (e) {
+ return desired;
+ }
+ if (!hit || !hit.hit || hit.distance >= len - 0.01) {
+ return desired;
+ }
+ // Сокращаем дистанцию.
+ const clampedLen = Math.max(0.3, hit.distance - PADDING);
+ return new Vector3(
+ ex + dir.x * clampedLen,
+ ey + dir.y * clampedLen,
+ ez + dir.z * clampedLen
+ );
+ }
+
+ // ===== Управление камерой из скрипта (Фаза 5.7) =====
+
+ /** Установить угол обзора камеры (FOV) в градусах. */
+ setCameraFov(degrees) {
+ if (!this.camera) return;
+ const d = Number(degrees);
+ if (!Number.isFinite(d) || d < 10 || d > 130) return;
+ this.camera.fov = d * Math.PI / 180;
+ }
+
+ /**
+ * Привязать камеру к объекту — она смотрит на него.
+ * getTarget — функция, возвращающая {x,y,z} цели.
+ * opts: { distance, height } — отступ камеры от цели.
+ */
+ cameraFocusOn(getTarget, opts = {}) {
+ if (typeof getTarget !== 'function') return;
+ this._cameraOverride = {
+ mode: 'focus',
+ getTarget,
+ distance: Number.isFinite(opts.distance) ? opts.distance : 8,
+ height: Number.isFinite(opts.height) ? opts.height : 4,
+ };
+ }
+
+ /**
+ * Катсцена — плавный пролёт камеры по точкам.
+ * points — массив {x,y,z} позиций камеры.
+ * lookAt — массив {x,y,z} точек взгляда (по одной на каждую позицию).
+ * segDuration — секунд на отрезок между точками.
+ * onDone — колбэк по завершении.
+ */
+ cameraCutscene(points, lookAt, segDuration, onDone) {
+ if (!Array.isArray(points) || points.length < 2) return;
+ this._cameraOverride = {
+ mode: 'cutscene',
+ points,
+ lookAt: Array.isArray(lookAt) ? lookAt : [],
+ segDuration: Number.isFinite(segDuration) && segDuration > 0 ? segDuration : 2,
+ t: 0, // время от начала
+ seg: 0, // текущий отрезок
+ onDone: typeof onDone === 'function' ? onDone : null,
+ };
+ }
+
+ /** Вернуть камеру под управление игрока. */
+ cameraReset() {
+ this._cameraOverride = null;
+ }
+
+ // ===== Задача 14: вождение машины =====
+ /** Усадить игрока в машину veh (объект из VehicleManager). */
+ enterVehicle(veh) {
+ if (!veh) return;
+ this._inVehicle = veh;
+ this._vehicleCamMode = 'follow';
+ veh.driver = 'player';
+ if (this._codes) this._codes.clear(); // сброс остаточных WASD от ходьбы
+ this._skinVisibleScripted = false; // спрятать аватар (сидит в машине)
+ this._startEngineSound();
+ }
+
+ /**
+ * Звук мотора: низкочастотный РОКОТ (а не воющий тон).
+ * Бас-пила (40-90 Гц) + отфильтрованный шум для «рыка» + LFO-пульсация
+ * громкости (имитация тактов двигателя). Частота тактов и фильтр растут
+ * со скоростью, но базовый тон остаётся низким — не сирена.
+ */
+ _startEngineSound() {
+ try {
+ if (!this._audioCtx) {
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ this._audioCtx = new Ctx();
+ }
+ const ctx = this._audioCtx;
+ if (ctx.state === 'suspended') ctx.resume();
+ if (this._engineNodes) return; // уже играет
+
+ // 1) Бас-тон мотора (низкий, тёплый).
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.value = 45; // холостой ход — низко
+ // 2) Шум через узкий lowpass — «рык» выхлопа.
+ const bufLen = ctx.sampleRate * 1.0;
+ const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
+ const data = buf.getChannelData(0);
+ for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.6;
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf; noise.loop = true;
+ const noiseLp = ctx.createBiquadFilter();
+ noiseLp.type = 'lowpass'; noiseLp.frequency.value = 180; noiseLp.Q.value = 0.7;
+ const noiseGain = ctx.createGain();
+ noiseGain.gain.value = 0.35;
+ // Общий lowpass — глушит верха, чтобы не свистело.
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
+ // 3) LFO-пульсация громкости (такты двигателя ~12 Гц на холостых).
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine'; lfo.frequency.value = 12;
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.value = 0.18; // глубина пульсации
+ const gain = ctx.createGain();
+ gain.gain.value = 0.05; // общая громкость (растёт со скоростью)
+
+ osc.connect(lp);
+ noise.connect(noiseLp); noiseLp.connect(noiseGain); noiseGain.connect(lp);
+ lp.connect(gain); gain.connect(ctx.destination);
+ // LFO модулирует gain.gain вокруг базового значения.
+ lfo.connect(lfoGain); lfoGain.connect(gain.gain);
+
+ osc.start(); noise.start(); lfo.start();
+ this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
+ } catch (e) { /* ignore */ }
+ }
+
+ _updateEngineSound(speedMs, maxSpeed) {
+ const n = this._engineNodes;
+ if (!n) return;
+ try {
+ const frac = Math.min(1, Math.abs(speedMs) / (maxSpeed || 14));
+ const ctx = this._audioCtx; const t = ctx.currentTime;
+ // Базовый тон остаётся НИЗКИМ: 45 Гц холостые → ~95 Гц на максималке.
+ n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
+ // Частота тактов (LFO): 12 Гц холостые → ~45 Гц на ходу (плотнее рокот).
+ n.lfo.frequency.setTargetAtTime(12 + frac * 33, t, 0.12);
+ // Фильтры чуть открываются — больше «рыка», но без визга.
+ n.lp.frequency.setTargetAtTime(300 + frac * 500, t, 0.12);
+ n.noiseLp.frequency.setTargetAtTime(150 + frac * 350, t, 0.12);
+ n.noiseGain.gain.setTargetAtTime(0.25 + frac * 0.35, t, 0.12);
+ // Громкость: 0.05 холостые → 0.13 в движении.
+ n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
+ } catch (e) { /* ignore */ }
+ }
+
+ _stopEngineSound() {
+ const n = this._engineNodes;
+ if (!n) return;
+ try {
+ const t = this._audioCtx.currentTime;
+ n.gain.gain.setTargetAtTime(0, t, 0.05);
+ n.osc.stop(t + 0.2);
+ n.noise.stop(t + 0.2);
+ n.lfo.stop(t + 0.2);
+ } catch (e) { /* ignore */ }
+ this._engineNodes = null;
+ }
+
+ /** Высадить игрока из машины (вернуть ходьбу). */
+ exitVehicle() {
+ const veh = this._inVehicle;
+ this._inVehicle = null;
+ if (veh) {
+ veh.driver = null;
+ // Поставить игрока сбоку от машины.
+ try {
+ const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
+ this._pos.set(veh.pos.x + side.x * (veh.half.w + 1.0), veh.pos.y + 1.0, veh.pos.z + side.z * (veh.half.w + 1.0));
+ this._vy = 0;
+ } catch (e) {}
+ }
+ this._stopEngineSound();
+ this._skinVisibleScripted = true;
+ // Вернуть видимость аватара.
+ if (this._modelMeshes) {
+ for (const m of this._modelMeshes) {
+ if (m && m.setEnabled) { try { m.setEnabled(true); } catch (e) {} }
+ }
+ }
+ }
+
+ /** Циклически сменить режим камеры машины (клавиша V). */
+ cycleVehicleCamera() {
+ const modes = ['follow', 'hood', 'cinematic'];
+ const i = modes.indexOf(this._vehicleCamMode || 'follow');
+ this._vehicleCamMode = modes[(i + 1) % modes.length];
+ }
+
+ /** Шаг вождения: читаем WASD, рулим машиной, ставим camera follow/hood. */
+ _tickVehicle(dt) {
+ const veh = this._inVehicle;
+ if (!veh || !this._scene3d?.vehicleManager) return;
+ // Скрываем аватар игрока (сидит в машине) — каждый кадр, т.к. обычный
+ // хвост _tick (где применяется _skinVisibleScripted) пропускается из-за return.
+ if (this._modelMeshes) {
+ for (const m of this._modelMeshes) {
+ if (m && m.isEnabled && m.isEnabled() && m.setEnabled) { try { m.setEnabled(false); } catch (e) {} }
+ }
+ }
+ const c = this._codes;
+ const throttle = (c.has('KeyW') || c.has('ArrowUp') ? 1 : 0) - (c.has('KeyS') || c.has('ArrowDown') ? 1 : 0);
+ const steer = (c.has('KeyD') || c.has('ArrowRight') ? 1 : 0) - (c.has('KeyA') || c.has('ArrowLeft') ? 1 : 0);
+ const handbrake = c.has('Space');
+ this._scene3d.vehicleManager.setInput(veh, throttle, steer, handbrake);
+ const res = this._scene3d.vehicleManager.tickVehicle(veh, dt);
+ this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
+
+ // Упали в бездну на машине → выйти + респавн на точке старта.
+ if (res && res.fellOut) {
+ this.exitVehicle();
+ if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (e) {} }
+ const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
+ try { this._pos.set(sp.x, sp.y + 1.0, sp.z); this._vy = 0; } catch (e) {}
+ return;
+ }
+
+ // Синхронизируем позицию игрока с машиной (для квестов/выхода).
+ try { this._pos.set(veh.pos.x, veh.pos.y + 1.0, veh.pos.z); } catch (e) {}
+
+ // Камера машины.
+ if (!this.camera) return;
+ const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
+ const cp = veh.pos;
+ const mode = this._vehicleCamMode || 'follow';
+ let camPos, camTarget;
+ if (mode === 'hood') {
+ camPos = new Vector3(cp.x + dir.x * (veh.half.d + 0.3), cp.y + veh.half.h + 0.6, cp.z + dir.z * (veh.half.d + 0.3));
+ camTarget = new Vector3(cp.x + dir.x * 8, cp.y + 0.5, cp.z + dir.z * 8);
+ } else if (mode === 'cinematic') {
+ const side = new Vector3(Math.cos(veh.yaw), 0, -Math.sin(veh.yaw));
+ camPos = new Vector3(cp.x + side.x * 7 + dir.x * 2, cp.y + 2.5, cp.z + side.z * 7 + dir.z * 2);
+ camTarget = new Vector3(cp.x, cp.y + 0.8, cp.z);
+ } else { // follow
+ camPos = new Vector3(cp.x - dir.x * 8, cp.y + 3.2, cp.z - dir.z * 8);
+ camTarget = new Vector3(cp.x + dir.x * 2, cp.y + 1.0, cp.z + dir.z * 2);
+ }
+ // Плавный lerp позиции камеры (без рывков на поворотах).
+ const k = Math.min(1, dt * 6);
+ this.camera.position.set(
+ this.camera.position.x + (camPos.x - this.camera.position.x) * k,
+ this.camera.position.y + (camPos.y - this.camera.position.y) * k,
+ this.camera.position.z + (camPos.z - this.camera.position.z) * k,
+ );
+ try { this.camera.setTarget(camTarget); } catch (e) {}
+ }
+
+ /** Применить активный режим камеры скрипта (вызывается в _tick). */
+ _applyCameraOverride(dt) {
+ const o = this._cameraOverride;
+ if (!o || !this.camera) return;
+ if (o.mode === 'focus') {
+ const t = o.getTarget();
+ if (!t) return;
+ // Камера позади-сверху цели, смотрит на неё.
+ this.camera.position.set(t.x, t.y + o.height, t.z + o.distance);
+ this.camera.setTarget(new Vector3(t.x, t.y, t.z));
+ } else if (o.mode === 'cutscene') {
+ // Клампим dt: на тяжёлых кадрах (загрузка сцены, спавн GLB)
+ // dt может скакнуть до 0.5-2с — тогда катсцена «проматывается»
+ // за пару кадров. Ограничиваем шаг 1/30с — катсцена идёт
+ // ровно свою длительность независимо от лагов.
+ o.t += Math.min(dt, 1 / 30);
+ const segCount = o.points.length - 1;
+ // Прогресс по текущему отрезку [0..1].
+ let local = o.t / o.segDuration;
+ let seg = Math.floor(o.t / o.segDuration);
+ if (seg >= segCount) {
+ // Катсцена завершена — встаём на последнюю точку.
+ const last = o.points[o.points.length - 1];
+ this.camera.position.set(last.x, last.y, last.z);
+ const lookLast = o.lookAt[o.lookAt.length - 1];
+ if (lookLast) this.camera.setTarget(new Vector3(lookLast.x, lookLast.y, lookLast.z));
+ const cb = o.onDone;
+ this._cameraOverride = null;
+ if (cb) { try { cb(); } catch (e) { /* ignore */ } }
+ return;
+ }
+ local = local - seg; // дробная часть = прогресс отрезка
+ // Сглаживание (ease-in-out) — плавный пролёт.
+ const k = local < 0.5
+ ? 2 * local * local
+ : 1 - Math.pow(-2 * local + 2, 2) / 2;
+ const a = o.points[seg], b = o.points[seg + 1];
+ this.camera.position.set(
+ a.x + (b.x - a.x) * k,
+ a.y + (b.y - a.y) * k,
+ a.z + (b.z - a.z) * k,
+ );
+ // Точка взгляда — интерполяция между соседними lookAt.
+ const la = o.lookAt[seg], lb = o.lookAt[seg + 1] || o.lookAt[seg];
+ if (la && lb) {
+ this.camera.setTarget(new Vector3(
+ la.x + (lb.x - la.x) * k,
+ la.y + (lb.y - la.y) * k,
+ la.z + (lb.z - la.z) * k,
+ ));
+ }
+ }
+ }
+
+ /**
+ * Применить текущий режим камеры:
+ * - В 1st person скрываем модель игрока (видим только сцену)
+ * - В 3rd person и front показываем
+ */
+ _applyCameraMode() {
+ const visible = this._cameraMode !== 'first';
+ for (const m of this._modelMeshes) {
+ m.setEnabled(visible);
+ }
+ // Сообщаем оружию что режим камеры сменился — чтобы перепарентить
+ // view-model (камера в 1-st, модель игрока в 3-rd).
+ if (this._scene3d?.weapons?.onCameraModeChange) {
+ this._scene3d.weapons.onCameraModeChange(this._cameraMode);
+ }
+ }
+
+ /**
+ * УНИВЕРСАЛЬНЫЙ механизм управления частями тела модели.
+ *
+ * Установить override-rotation для именованного меша поверх анимации.
+ * Применяется КАЖДЫЙ КАДР — анимация сначала пишет rotationQuaternion,
+ * потом наш _applyMeshRotationOverrides() обнуляет quaternion и пишет
+ * наши углы Эйлера.
+ *
+ * meshName — имя меша как в GLB ('arm-right', 'arm-left', 'head', ...).
+ * Без префикса 'player_'.
+ * rotation — Vector3 углов Эйлера (rad). null → снять override.
+ *
+ * Это база для будущих кастомных поз/анимаций (стрельба, IK, жесты).
+ */
+ setMeshRotationOverride(meshName, rotation) {
+ // R15-скин: «меш руки» — это кость RightUpperArm. WeaponSystem зовёт
+ // этот метод для позы/замаха — переадресуем на override кости.
+ // R15Animator каждый кадр ставит rest+анимацию; override кости
+ // применяется поверх в _applyR15BoneOverrides() после update().
+ if (this._isR15) {
+ if (!this._r15BoneOverrides) this._r15BoneOverrides = new Map();
+ // Имя меша Kenney ('arm-right'/...) маппим на логическую R15-кость.
+ const lower = (meshName || '').toLowerCase();
+ const logical = (lower.includes('right')) ? 'RightUpperArm'
+ : (lower.includes('left')) ? 'LeftUpperArm'
+ : 'RightUpperArm';
+ if (rotation == null) {
+ this._r15BoneOverrides.delete(logical);
+ } else {
+ this._r15BoneOverrides.set(logical,
+ rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
+ }
+ return;
+ }
+ if (!this._meshRotationOverrides) this._meshRotationOverrides = new Map();
+ if (rotation == null) {
+ this._meshRotationOverrides.delete(meshName);
+ } else {
+ this._meshRotationOverrides.set(meshName,
+ rotation.clone ? rotation.clone() : new Vector3(rotation.x, rotation.y, rotation.z));
+ }
+ }
+
+ /**
+ * Применить override-повороты костей R15 поверх процедурной анимации.
+ * Вызывается после R15Animator.update(). Используется WeaponSystem
+ * для позы руки с оружием / melee-замаха.
+ */
+ _applyR15BoneOverrides() {
+ const map = this._r15BoneOverrides;
+ if (!map || map.size === 0 || !this._r15Skeleton) return;
+ for (const [logical, rot] of map.entries()) {
+ const bone = this._r15Skeleton.resolveBone(logical);
+ if (!bone) continue;
+ // Override задаётся как абсолютный локальный поворот кости
+ // (Эйлер). Перекрывает то, что поставил аниматор этим кадром.
+ const q = Quaternion.RotationYawPitchRoll(rot.y, rot.x, rot.z);
+ bone.setRotationQuaternion(q, Space.LOCAL);
+ }
+ }
+
+ /** Получить меш модели по короткому имени (без префикса 'player_'). */
+ getModelMesh(meshName) {
+ if (!this._modelMeshes) return null;
+ const target = `player_${meshName}`;
+ return this._modelMeshes.find(m => m.name === target) || null;
+ }
+
+ /**
+ * Применить все активные override'ы. Вызывается каждый кадр в _tick
+ * ПОСЛЕ обновления анимации (registerBeforeRender срабатывает после
+ * _animate). Анимация Kenney пишет в rotationQuaternion → обнуляем его
+ * каждый кадр и пишем в .rotation.
+ */
+ _applyMeshRotationOverrides() {
+ const map = this._meshRotationOverrides;
+ if (!map || map.size === 0) return;
+ for (const [meshName, rot] of map.entries()) {
+ const mesh = this.getModelMesh(meshName);
+ if (!mesh) continue;
+ if (mesh.rotationQuaternion) {
+ mesh.rotationQuaternion = null;
+ }
+ mesh.rotation.x = rot.x;
+ mesh.rotation.y = rot.y;
+ mesh.rotation.z = rot.z;
+ }
+ }
+
+ /**
+ * Включить/выключить «позу с оружием».
+ * Делает 2 вещи независимо:
+ * 1. Override rotation на МЕШе правой руки (поднимает реальную руку Kenney).
+ * 2. Создаёт ЧИСТЫЙ TransformNode armAnchor на плече (ориентация совпадает
+ * с _modelRoot). К нему WeaponSystem парентит бластер с rotation 0,
+ * и дуло автоматически смотрит вперёд персонажа.
+ *
+ * Эти два механизма НЕ ЗАВИСЯТ друг от друга — мы не пытаемся вычислять
+ * ориентацию повёрнутого меша руки.
+ */
+ _updateExtendedArm(hasWeapon) {
+ // === R15-скин: якорь оружия на кости RightHand ===
+ // У R15 нет меша-руки — есть кость. Якорь привязываем к кости
+ // через attachToBone: оружие следует за рукой при анимации.
+ if (this._isR15 && this._r15Skeleton) {
+ const showWeapon = hasWeapon && this._cameraMode !== 'first';
+ if (showWeapon && !this._weaponAnchor) {
+ const handBone = this._r15Skeleton.resolveBone('RightHand');
+ const skinMesh = this._modelMeshes?.find((m) => m.skeleton) || this._modelMeshes?.[0];
+ if (handBone && skinMesh) {
+ this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
+ // attachToBone — якорь следует за костью каждый кадр.
+ this._weaponAnchor.attachToBone(handBone, skinMesh);
+ // Небольшой сдвиг чтобы оружие легло в ладонь, не в запястье.
+ this._weaponAnchor.position.set(0, 0.05, 0.1);
+ }
+ }
+ if (this._weaponAnchor) {
+ this._weaponAnchor.setEnabled(showWeapon);
+ }
+ return;
+ }
+
+ const armMesh = this._rightArmMeshes?.[0];
+ if (!armMesh) return;
+ const meshName = (armMesh.name || '').replace(/^player_/, '');
+ const showWeapon = hasWeapon && this._cameraMode !== 'first';
+
+ // 1) Поза руки через override
+ if (showWeapon) {
+ this.setMeshRotationOverride(meshName, new Vector3(-Math.PI / 2, 0, 0));
+ } else {
+ this.setMeshRotationOverride(meshName, null);
+ }
+
+ // 2) ChIstый якорь для оружия — TransformNode на плече персонажа,
+ // ориентация совпадает с _modelRoot (без поворотов).
+ if (showWeapon && !this._weaponAnchor && this._modelRoot) {
+ this._weaponAnchor = new TransformNode('weaponAnchor', this.scene);
+ this._weaponAnchor.parent = this._modelRoot;
+ // Координаты в _modelRoot имеют ОТЗЕРКАЛЕННЫЙ X относительно меша.
+ // Плечо: y = origin + 0.7 (выше).
+ // X: сдвигаем чуть наружу (ещё правее).
+ const sx = -(armMesh.position?.x ?? -0.4) + 0.15;
+ const sy = (armMesh.position?.y ?? 1.1) + 0.7;
+ const sz = (armMesh.position?.z ?? 0) + 0.95;
+ this._weaponAnchor.position.set(sx, sy, sz);
+ }
+ if (this._weaponAnchor) {
+ this._weaponAnchor.setEnabled(showWeapon);
+ }
+ }
+
+ getWeaponAnchor() { return this._weaponAnchor || null; }
+
+ /** Цикл first ↔ third. */
+ _toggleCameraMode() {
+ const idx = CAMERA_MODES.indexOf(this._cameraMode);
+ this._cameraMode = CAMERA_MODES[(idx + 1) % CAMERA_MODES.length];
+ this._applyCameraMode();
+ // При переходе в first сразу лочим, при выходе — снимаем lock (если нет shift-lock)
+ if (this._cameraMode === 'first') {
+ this._requestPointerLockSafe();
+ } else if (!this._shiftLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ this._applyCursorVisibility?.();
+ }
+
+ /** Включить/выключить Shift-Lock (Roblox-style: курсор зафиксирован, корпус
+ * всегда лицом к камере, камера через плечо).
+ */
+ setShiftLock(on) {
+ this._shiftLock = !!on;
+ if (this._shiftLock) {
+ // Запросить pointer-lock — курсор в центре
+ this._requestPointerLockSafe();
+ } else {
+ // Снять lock если он есть и нет других причин держать (first/sideview)
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview'
+ );
+ if (!needPermLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ }
+ this._applyCursorVisibility?.();
+ }
+ isShiftLock() { return !!this._shiftLock; }
+
+ /** Задача 04: блокирует игровой ввод (WASD/Space/Ctrl/Shift/etc).
+ * Не блокирует Esc/Tab/Enter (нужны для GUI).
+ * Также сбрасывает накопленные клавиши чтобы движение остановилось. */
+ setInputBlocked(blocked) {
+ this._inputBlocked = !!blocked;
+ if (this._inputBlocked) {
+ try { this._codes?.clear(); } catch (e) {}
+ this._shift = false;
+ // Снимаем pointer-lock — иначе мышь застрянет «в режиме игры»
+ try {
+ if (document.pointerLockElement === this.canvas) document.exitPointerLock();
+ } catch (e) {}
+ }
+ }
+ isInputBlocked() { return !!this._inputBlocked; }
+
+ /** Задача 04: замораживает камеру (не вращается мышью, не зумится колесом). */
+ setCameraFrozen(frozen) {
+ this._cameraFrozen = !!frozen;
+ }
+ isCameraFrozen() { return !!this._cameraFrozen; }
+
+ /** Задача 04: снимок состояния камеры — для восстановления после модала. */
+ captureCameraState() {
+ return {
+ yaw: this._yaw,
+ pitch: this._pitch,
+ cameraMode: this._cameraMode,
+ thirdDistance: this._thirdDistance,
+ fov: this.scene?.activeCamera?.fov,
+ playerPos: this._pos ? {
+ x: this._pos.x, y: this._pos.y, z: this._pos.z
+ } : null,
+ };
+ }
+
+ /** Задача 04: восстановить состояние камеры из снимка. */
+ restoreCameraState(s) {
+ if (!s) return;
+ if (Number.isFinite(s.yaw)) this._yaw = s.yaw;
+ if (Number.isFinite(s.pitch)) this._pitch = s.pitch;
+ if (s.cameraMode) {
+ this._cameraMode = s.cameraMode;
+ try { this._applyCameraMode?.(); } catch (e) {}
+ }
+ if (Number.isFinite(s.thirdDistance)) this._thirdDistance = s.thirdDistance;
+ if (Number.isFinite(s.fov) && this.scene?.activeCamera) {
+ this.scene.activeCamera.fov = s.fov;
+ }
+ }
+
+ /** Задача 04: камера-фокус на reference (cube/npc/cam-target).
+ * ref может быть: строка-id, ref-объект ({kind, id}), Mesh, или {x,y,z}.
+ * Использует уже существующий механизм camera.focus в GameRuntime, но
+ * здесь упрощённая обёртка: ставим yaw/pitch так, чтобы смотреть на цель,
+ * и зум на distance. */
+ focusOnTarget(ref, opts) {
+ opts = opts || {};
+ const distance = Number.isFinite(opts.distance) ? opts.distance : 8;
+ const height = Number.isFinite(opts.height) ? opts.height : 3;
+ const fov = Number.isFinite(opts.fov) ? opts.fov : null;
+ let target = null;
+ if (ref && typeof ref === 'object' && 'x' in ref && 'y' in ref && 'z' in ref) {
+ target = ref;
+ } else {
+ const m = this._resolveTargetMesh(ref);
+ if (m) {
+ const p = m.getAbsolutePosition?.() || m.position;
+ target = { x: p.x, y: p.y, z: p.z };
+ }
+ }
+ if (!target) return;
+ // Прицельный взгляд: позиция камеры за игроком на distance, направление — на target
+ // Но мы во freezeCamera режиме — лучше через прямой override yaw/pitch.
+ if (!this._pos) return;
+ const dx = target.x - this._pos.x;
+ const dz = target.z - this._pos.z;
+ const dy = target.y - this._pos.y;
+ const horiz = Math.hypot(dx, dz);
+ this._yaw = Math.atan2(dx, dz);
+ this._pitch = Math.max(-1.4, Math.min(1.4, -Math.atan2(dy, horiz)));
+ this._thirdDistance = distance;
+ if (this._cameraMode !== 'third') {
+ this._cameraMode = 'third';
+ try { this._applyCameraMode?.(); } catch (e) {}
+ }
+ if (fov && this.scene?.activeCamera) {
+ this.scene.activeCamera.fov = fov * Math.PI / 180;
+ }
+ }
+
+ _resolveTargetMesh(ref) {
+ if (!ref) return null;
+ if (ref.getScene && typeof ref.getScene === 'function') return ref;
+ const sc = this._scene3d || this.scene3d;
+ const idStr = (typeof ref === 'string') ? ref : (ref.id || ref.refId || null);
+ if (!idStr || !sc) return null;
+ const tries = [
+ () => sc.primitiveManager?.getMesh?.(idStr),
+ () => sc.modelManager?.getInstanceMeshes?.(idStr)?.[0],
+ () => sc.scene?.getMeshByName?.(idStr),
+ () => sc.npcManager?.getMeshes?.(idStr)?.[0],
+ ];
+ for (const fn of tries) {
+ try { const r = fn(); if (r) return r; } catch (e) {}
+ }
+ return null;
+ }
+
+ /** Прямо установить дистанцию камеры (для third). Кламп в min/max. */
+ setCameraZoom(distance) {
+ const d = Number(distance);
+ if (!Number.isFinite(d)) return;
+ this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
+ Math.min(this.THIRD_DISTANCE_MAX, d));
+ // Авто-переход third↔first если пересекли порог
+ if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD
+ && this._cameraMode === 'third') {
+ this._cameraMode = 'first';
+ this._applyCameraMode?.();
+ this._requestPointerLockSafe();
+ } else if (this._thirdDistance > this.FIRST_PERSON_ZOOM_THRESHOLD
+ && this._cameraMode === 'first' && !this._lockFirstPerson) {
+ this._cameraMode = 'third';
+ this._applyCameraMode?.();
+ if (!this._shiftLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ }
+ }
+ /** Установить границы зума колеса. */
+ setCameraZoomLimits(min, max) {
+ const mn = Number(min), mx = Number(max);
+ if (Number.isFinite(mn) && mn >= 0) this.THIRD_DISTANCE_MIN = mn;
+ if (Number.isFinite(mx) && mx > 0) this.THIRD_DISTANCE_MAX = mx;
+ // Перекламп текущей дистанции
+ this._thirdDistance = Math.max(this.THIRD_DISTANCE_MIN,
+ Math.min(this.THIRD_DISTANCE_MAX, this._thirdDistance));
+ }
+ /** Поведение мыши: default / lockcenter / lockcurrent.
+ * default — свободный курсор (стандартный browser cursor).
+ * lockcenter — pointer-lock (курсор скрыт, mousemove даёт movementX/Y).
+ * lockcurrent — pointer-lock, но без скрытия (визуально как default,
+ * реально движение отслеживается через movementX/Y).
+ */
+ setMouseBehavior(mode) {
+ if (mode !== 'default' && mode !== 'lockcenter' && mode !== 'lockcurrent') return;
+ this._mouseBehavior = mode;
+ if (mode === 'default') {
+ // Снимаем lock если ничто другое не требует его
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (!needPermLock && document.pointerLockElement === this.canvas) {
+ try { document.exitPointerLock(); } catch (e) {}
+ }
+ } else {
+ this._requestPointerLockSafe();
+ }
+ this._applyCursorVisibility?.();
+ }
+ /** Видимость курсора (для third без lock). */
+ setMouseIconVisible(visible) {
+ this._mouseIconVisible = !!visible;
+ this._applyCursorVisibility?.();
+ }
+
+ _setupInput() {
+ const canvas = this.canvas;
+
+ const onCanvasClick = () => {
+ // В UI-режиме клик не перехватывает мышь.
+ if (this._uiCursorMode) return;
+ if (!this._active) return;
+ // Roblox-style: в third-person ЛКМ-клик НЕ должен лочить курсор —
+ // курсор остаётся свободным для GUI/3D-onClick. Lock запрашиваем
+ // ТОЛЬКО для режимов где курсор постоянно скрыт (first/lockfirst/
+ // sideview/shiftLock), и только если по какой-то причине lock сняли
+ // (например, юзер нажал Esc в first-режиме — надо вернуть lock).
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (!needPermLock) return;
+ if (document.pointerLockElement !== canvas) {
+ try {
+ const p = canvas.requestPointerLock?.();
+ if (p && typeof p.catch === 'function') p.catch(() => {});
+ } catch (e) { /* ignore */ }
+ }
+ };
+ canvas.addEventListener('click', onCanvasClick);
+
+ // === ПКМ: в third-person удержание ПКМ запускает orbit-камеру ===
+ // Roblox-style: зажал ПКМ → курсор скрыт, мышь крутит камеру.
+ // Отпустил → курсор вернулся на ту же позицию (браузер сам ставит).
+ const onCanvasMouseDownGlobal = (e) => {
+ if (!this._active || this._uiCursorMode) return;
+ if (e.button !== 2) return; // только ПКМ
+ // В режимах с постоянным lock'ом ПКМ ничего не делает
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (needPermLock) return;
+ // Запрашиваем lock — теперь mouseMove будет крутить камеру.
+ this._rmbHeld = true;
+ if (document.pointerLockElement !== canvas) {
+ try {
+ const p = canvas.requestPointerLock?.();
+ if (p && typeof p.catch === 'function') p.catch(() => {});
+ } catch (err) { /* ignore */ }
+ }
+ e.preventDefault();
+ };
+ const onWindowMouseUpGlobal = (e) => {
+ if (e.button !== 2) return;
+ if (!this._rmbHeld) return;
+ this._rmbHeld = false;
+ // Отпускаем lock только если он был включён нами для orbit-камеры
+ // (т.е. сейчас НЕ режим с постоянным lock).
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (needPermLock) return;
+ if (document.pointerLockElement === canvas) {
+ try { document.exitPointerLock(); } catch (err) { /* ignore */ }
+ }
+ };
+ canvas.addEventListener('mousedown', onCanvasMouseDownGlobal);
+ window.addEventListener('mouseup', onWindowMouseUpGlobal);
+ // Подавляем контекстное меню браузера на canvas (ПКМ — наш orbit-trigger).
+ canvas.addEventListener('contextmenu', (e) => {
+ if (this._active) e.preventDefault();
+ });
+
+ // === UI-режим: mousedown / mouseup → callback (для drag-игр) ===
+ const onCanvasMouseDown = (e) => {
+ if (!this._uiCursorMode) return;
+ if (typeof this._uiMouseDownCb !== 'function') return;
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ const y = (e.clientY - rect.top) / rect.height;
+ if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
+ try { this._uiMouseDownCb(x, y); } catch (err) { /* ignore */ }
+ }
+ };
+ const onCanvasMouseUp = (e) => {
+ if (!this._uiCursorMode) return;
+ if (typeof this._uiMouseUpCb !== 'function') return;
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ const y = (e.clientY - rect.top) / rect.height;
+ try { this._uiMouseUpCb(x, y); } catch (err) { /* ignore */ }
+ };
+ canvas.addEventListener('mousedown', onCanvasMouseDown);
+ // mouseup ловим на document — мышь могла уйти за пределы канваса
+ document.addEventListener('mouseup', onCanvasMouseUp);
+
+ const onMouseMove = (e) => {
+ // === UI-режим: транслируем нормализованные [0..1] координаты ===
+ // подписчику (Worker через GameRuntime). Используется для drag-игр
+ // типа Дальгона.
+ if (this._uiCursorMode && typeof this._uiMouseMoveCb === 'function') {
+ const rect = canvas.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ const y = (e.clientY - rect.top) / rect.height;
+ // Кидаем только если внутри канваса
+ if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
+ try { this._uiMouseMoveCb(x, y); } catch (err) { /* ignore */ }
+ }
+ }
+ if (document.pointerLockElement !== canvas) return;
+ // Кубикон Dash: в sideview мышь не вращает камеру.
+ if (this._cameraMode === 'sideview') return;
+ // Задача 04: модал с freezeCamera — мышь не вращает.
+ if (this._cameraFrozen) return;
+ this._yaw += e.movementX * this.MOUSE_SENSITIVITY;
+ this._pitch += e.movementY * this.MOUSE_SENSITIVITY;
+ const lim = Math.PI / 2 - 0.05;
+ if (this._pitch > lim) this._pitch = lim;
+ if (this._pitch < -lim) this._pitch = -lim;
+ };
+ document.addEventListener('mousemove', onMouseMove);
+
+ // Колесо: zoom в third + авто-переключение third ↔ first.
+ // Roblox-style: дистанция ≤ FIRST_PERSON_ZOOM_THRESHOLD → first-person
+ // (с pointer-lock). Колесо наружу из first → возврат в third.
+ const onWheel = (e) => {
+ if (!this._active) return;
+ if (this._cameraMode === 'sideview') return;
+ // Задача 04: модал с freezeCamera — колесо не зумит.
+ if (this._cameraFrozen) { e.preventDefault(); return; }
+ // В first-режиме колесо вверх НЕ работает (если lockfirst), вниз
+ // выходит обратно в third (если zoomable first, не lockfirst).
+ if (this._cameraMode === 'first') {
+ if (this._lockFirstPerson) { e.preventDefault(); return; }
+ if (e.deltaY > 0) {
+ // Колесо вниз → отдалить → переход в third
+ this._cameraMode = 'third';
+ this._thirdDistance = this.FIRST_PERSON_ZOOM_THRESHOLD + 0.5;
+ this._applyCameraMode?.();
+ // Снять pointer-lock — в third без shift-lock курсор виден
+ if (!this._shiftLock && document.pointerLockElement === canvas) {
+ try { document.exitPointerLock(); } catch (err) {}
+ }
+ }
+ e.preventDefault();
+ return;
+ }
+ if (this._cameraMode !== 'third') return;
+ // Шаг зума — пропорционален текущей дистанции (экспоненциальный фил)
+ const step = Math.max(0.3, this._thirdDistance * 0.15);
+ this._thirdDistance += Math.sign(e.deltaY) * step;
+ if (this._thirdDistance < this.THIRD_DISTANCE_MIN) this._thirdDistance = this.THIRD_DISTANCE_MIN;
+ if (this._thirdDistance > this.THIRD_DISTANCE_MAX) this._thirdDistance = this.THIRD_DISTANCE_MAX;
+ // Авто-переход в first при близком зуме (Roblox-style)
+ if (this._thirdDistance <= this.FIRST_PERSON_ZOOM_THRESHOLD) {
+ this._cameraMode = 'first';
+ this._applyCameraMode?.();
+ // Запросить pointer-lock — first всегда залочен
+ if (!this._shiftLock && document.pointerLockElement !== canvas) {
+ this._requestPointerLockSafe();
+ }
+ }
+ e.preventDefault();
+ };
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+
+ let wasLocked = false;
+ const onPointerLockChange = () => {
+ const locked = document.pointerLockElement === canvas;
+ if (locked) {
+ wasLocked = true;
+ this._rmbHeld = true; // если попал в lock — ПКМ удерживается
+ } else if (wasLocked && this._active) {
+ // pointer-lock снят. Причин три:
+ // 1) пользователь сам в UI-режиме (game.input.setCursorMode('ui'))
+ // 2) ПКМ отпущена в third-person (orbit-камера завершена)
+ // 3) Esc → выход из Play (если был в first/lockfirst/sideview)
+ this._rmbHeld = false;
+ if (this._uiCursorMode) {
+ this._applyCursorVisibility();
+ return;
+ }
+ const needPermLock = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._cameraMode === 'sideview' ||
+ this._shiftLock
+ );
+ if (needPermLock) {
+ // Был режим с постоянным lock'ом и его сняли → Esc → выход
+ if (this._onExitRequest) this._onExitRequest();
+ } else {
+ // Third-person: пользователь просто отпустил ПКМ. Курсор
+ // возвращается там же где был — это нормально, остаёмся в Play.
+ this._applyCursorVisibility();
+ }
+ }
+ };
+ document.addEventListener('pointerlockchange', onPointerLockChange);
+
+ const isTypingTarget = (target) => {
+ if (!target) return false;
+ const tag = (target.tagName || '').toLowerCase();
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
+ return !!target.isContentEditable;
+ };
+ const onKeyDown = (e) => {
+ if (!this._active) return;
+ if (isTypingTarget(e.target)) return;
+ // Задача 04: Esc в Play — приоритет закрытие модала через _onExitRequest
+ // (там стоит проверка modalManager.isOpen). Без явного перехвата Esc
+ // в third (без pointer-lock) сразу выходил из Play.
+ if (e.code === 'Escape') {
+ if (this._onExitRequest) {
+ this._onExitRequest();
+ return;
+ }
+ }
+ // Задача 04: модал блокирует игровой ввод (WASD/Space/Ctrl/Shift и т.п.),
+ // но НЕ блокирует Esc (нужен для закрытия модала через Esc-обработчик),
+ // и НЕ блокирует Tab/Enter (могут понадобиться в GUI-модалах).
+ if (this._inputBlocked && e.code !== 'Escape' && e.code !== 'Tab' && e.code !== 'Enter') {
+ // Глотаем preventDefault только для игровых клавиш
+ if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
+ return;
+ }
+ this._codes.add(e.code);
+ if (e.shiftKey) this._shift = true;
+ // Задача 14: в машине — V меняет камеру, E выходит.
+ if (this._inVehicle) {
+ if (e.code === 'KeyV') { this.cycleVehicleCamera(); }
+ else if (e.code === 'KeyE') {
+ const veh = this._inVehicle;
+ this.exitVehicle();
+ if (this._onVehicleExit) { try { this._onVehicleExit(veh); } catch (err) {} }
+ }
+ }
+ // C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
+ // и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
+ if (e.code === 'KeyC') {
+ const inGdMode = (this._autoRunSpeed || 0) > 0
+ || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
+ if (!inGdMode) this._toggleCameraMode();
+ }
+ // L — Shift-Lock (Roblox по умолчанию на Shift, у нас Shift = бег,
+ // поэтому переназначено на L). Курсор центрируется, корпус всегда
+ // лицом к камере, камера через плечо.
+ if (e.code === 'KeyL') {
+ this.setShiftLock(!this._shiftLock);
+ }
+ // B — встроенный магазин скинов (задача 07). Открывается только если
+ // включён в проекте (scene.skins.shopVisible). Toggle.
+ if (e.code === 'KeyB' && !this._inputBlocked) {
+ try { this._scene3d?.toggleSkinShop?.(); } catch (err) {}
+ }
+ // Tab — переключить «UI-режим курсора» (для кликов по GUI в Play)
+ if (e.code === 'Tab') {
+ e.preventDefault();
+ this.setUiCursorMode(!this._uiCursorMode);
+ }
+ if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
+ e.preventDefault();
+ }
+ // В GD-режиме блокируем Alt (открывает меню браузера + ломает фокус),
+ // Ctrl (приседание), C (смена камеры). Чтобы не было неожиданных побочек.
+ const inGdMode = (this._autoRunSpeed || 0) > 0
+ || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
+ if (inGdMode && ['AltLeft','AltRight','ControlLeft','ControlRight','KeyC'].includes(e.code)) {
+ e.preventDefault();
+ }
+ };
+ const onKeyUp = (e) => {
+ if (isTypingTarget(e.target)) return;
+ this._codes.delete(e.code);
+ if (!e.shiftKey) this._shift = false;
+ };
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+
+ const onBlur = () => {
+ this._codes.clear();
+ this._shift = false;
+ };
+ window.addEventListener('blur', onBlur);
+
+ this._listeners = [
+ { target: canvas, type: 'click', fn: onCanvasClick },
+ { target: canvas, type: 'wheel', fn: onWheel,
+ opts: { passive: false } },
+ { target: document, type: 'mousemove', fn: onMouseMove },
+ { target: document, type: 'pointerlockchange', fn: onPointerLockChange },
+ { target: window, type: 'keydown', fn: onKeyDown },
+ { target: window, type: 'keyup', fn: onKeyUp },
+ { target: window, type: 'blur', fn: onBlur },
+ ];
+ }
+
+ _tick() {
+ // dt cap: на лагающем редакторе кадр может быть 100-300мс. Старая
+ // логика 'if (dt > 0.1) return' пропускала физику целиком → персонаж
+ // не двигался, прыжок «застревал» в воздухе. Теперь зажимаем в 0.1
+ // (max 10 кадров/сек физики). Этого хватает чтобы движение было
+ // плавным даже на 5 FPS — просто чуть рывками.
+ let dt = this.scene.getEngine().getDeltaTime() / 1000;
+ if (dt <= 0) return;
+ if (dt > 0.1) dt = 0.1;
+
+ // === Задача 14: режим вождения — инпут идёт в машину, не в ходьбу ===
+ if (this._inVehicle) {
+ try { this._tickVehicle(dt); } catch (e) { /* ignore */ }
+ return;
+ }
+
+ // === Присед: по Ctrl на десктопе, или через мобильную кнопку
+ // (которая шлёт keydown 'ControlLeft'). C — НЕ используется
+ // (это смена вида в Babylon).
+ // В GD-режиме (auto-run > 0) приседание отключено — оно ломает физику
+ // (уменьшается HALF_H, игрок проваливается под коллизию и может пробить потолок в Ship).
+ const inGdMode = (this._autoRunSpeed || 0) > 0
+ || this._shipMode || this._ufoMode || this._waveMode || this._robotMode;
+ const wantCrouch = !inGdMode && this._codes
+ && (this._codes.has('ControlLeft') || this._codes.has('ControlRight'));
+ if (wantCrouch && !this._crouching) {
+ this._crouching = true;
+ // сдвигаем центр капсулы вниз — низ ног остаётся на земле
+ const dH = this.HALF_H_CROUCH - this.HALF_H;
+ this.HALF_H = this.HALF_H_CROUCH;
+ if (this._pos) this._pos.y += dH;
+ } else if (!wantCrouch && this._crouching && !this._scriptForcedCrouch) {
+ this._crouching = false;
+ const dH = this.HALF_H_NORMAL - this.HALF_H;
+ this.HALF_H = this.HALF_H_NORMAL;
+ if (this._pos) this._pos.y += dH;
+ }
+
+ // === Горизонтальное движение ===
+ const forward = new Vector3(Math.sin(this._yaw), 0, Math.cos(this._yaw));
+ const right = new Vector3(Math.cos(this._yaw), 0, -Math.sin(this._yaw));
+ const isSprinting = this._shift;
+ const speedMult = isSprinting ? this.SPRINT_MULT : 1;
+ const speed = this.WALK_SPEED * speedMult * (this._speedMul || 1) * dt;
+
+ let moveX = 0, moveZ = 0;
+ // c (codes) используется ниже для прыжка/Space — объявляем здесь,
+ // чтобы был доступен после if/else движения.
+ const c = this._codes;
+ const am = this._analogMove;
+ // === Кубикон Dash: авто-движение по +X, ввод заблокирован ===
+ // game.player.autoRun(speed) выставляет _autoRunSpeed > 0. В sideview-режиме
+ // игрок САМ движется по +X со скоростью speed (м/с), WASD/тач игнорируются.
+ if (this._cameraMode === 'sideview' && this._autoRunSpeed > 0) {
+ moveX = this._autoRunSpeed * dt;
+ moveZ = 0;
+ } else
+ // === Раннер от 3-го лица: авто-бег ВПЕРЁД по +Z ===
+ // В режимах third/first/front при _autoRunSpeed > 0 игрок сам бежит
+ // строго по мировому +Z (subway-runner). Направление ФИКСИРОВАНО —
+ // вращение камеры мышью НЕ меняет курс бега (иначе персонаж бежал
+ // бы туда, куда смотрит игрок). WASD/тач НЕ двигают: смену полос
+ // делает скрипт через player.teleport по X.
+ if (this._cameraMode !== 'sideview' && this._autoRunSpeed > 0) {
+ moveX = 0;
+ moveZ = this._autoRunSpeed * dt;
+ } else
+ // Аналоговый ввод (тач-джойстик) имеет приоритет над клавишами:
+ // позволяет двигаться в любом направлении плавно, не только в 8 секторов.
+ if (am && (Math.abs(am.x) > 0.01 || Math.abs(am.y) > 0.01)) {
+ // Магнитуда [0..1] — скорость (зажатый джойстик в край = sprint*).
+ // *На практике мы передаём sprint через setVirtualShift.
+ const mag = Math.min(1, Math.hypot(am.x, am.y));
+ // Нормализуем направление, скорость = mag * speed
+ const dirX = am.x / Math.max(0.0001, Math.hypot(am.x, am.y));
+ const dirY = am.y / Math.max(0.0001, Math.hypot(am.x, am.y));
+ const v = mag * speed;
+ // y=1 → вперёд (forward), x=1 → вправо (right)
+ moveX = forward.x * dirY * v + right.x * dirX * v;
+ moveZ = forward.z * dirY * v + right.z * dirX * v;
+ } else {
+ if (c.has('KeyW') || c.has('ArrowUp')) { moveX += forward.x * speed; moveZ += forward.z * speed; }
+ if (c.has('KeyS') || c.has('ArrowDown')) { moveX -= forward.x * speed; moveZ -= forward.z * speed; }
+ if (c.has('KeyD') || c.has('ArrowRight')) { moveX += right.x * speed; moveZ += right.z * speed; }
+ if (c.has('KeyA') || c.has('ArrowLeft')) { moveX -= right.x * speed; moveZ -= right.z * speed; }
+ }
+
+ const isMoving = (moveX !== 0 || moveZ !== 0);
+
+ // === ЛЁД: инерция движения ===
+ // Если _iceFriction > 0 — игрок «скользит». Хранимая скорость
+ // _iceVelX/_iceVelZ обновляется к target (текущий ввод за этот кадр)
+ // с коэффициентом ускорения, и затухает с (1 - friction*dt*8).
+ if (this._iceFriction > 0.001) {
+ const fric = Math.min(1, this._iceFriction);
+ // Чем выше friction, тем медленнее набираем скорость и тем дольше
+ // затухает после отпускания клавиш.
+ const accel = (1 - fric) * 0.4 + 0.05; // 0.05..0.45
+ const decay = 1 - (1 - fric) * 6 * dt; // ~0.7..1
+ // Целевая скорость = moveX/moveZ (уже содержит dt)
+ // Прибавляем разницу с ускорением
+ this._iceVelX += (moveX - this._iceVelX) * accel;
+ this._iceVelZ += (moveZ - this._iceVelZ) * accel;
+ if (!isMoving) {
+ this._iceVelX *= decay;
+ this._iceVelZ *= decay;
+ if (Math.abs(this._iceVelX) < 0.0001) this._iceVelX = 0;
+ if (Math.abs(this._iceVelZ) < 0.0001) this._iceVelZ = 0;
+ }
+ moveX = this._iceVelX;
+ moveZ = this._iceVelZ;
+ } else {
+ // Сбрасываем накопленную скорость когда лёд выкл
+ this._iceVelX = 0;
+ this._iceVelZ = 0;
+ }
+
+ // === Плавание (если AABB пересекает блок-воду) ===
+ const inWater = this._isInWater();
+ const submerged = this._isSubmerged();
+ if (inWater) {
+ // В воде движемся в 2 раза медленнее
+ moveX *= 0.5;
+ moveZ *= 0.5;
+ }
+
+ // === Вертикальное ===
+ if (inWater) {
+ // Плавание: лёгкая гравитация + плавучесть к поверхности
+ const buoyancy = submerged ? 6 : 0;
+ const swimGravity = -3;
+ this._vy += (buoyancy + swimGravity) * dt;
+ this._vy *= Math.max(0, 1 - 3 * dt);
+ if (this._codes.has('Space')) this._vy += 14 * dt;
+ if (this._vy > 4) this._vy = 4;
+ if (this._vy < -4) this._vy = -4;
+ } else {
+ // Кубикон Dash: умеренная усиленная гравитация для коротких
+ // "поппи" прыжков как в GD. ×1.35 даёт время полёта ~0.55с
+ // и высоту ~2.6м при jumpPower=1.5 — хватает на шип scaleY=1.2,
+ // не слишком улетает по X (~4-5м за прыжок).
+ // Variable-jump (отпускание Space обрезает vy) НЕ используется —
+ // в авто-беге игрок не контролирует длительность нажатия.
+ //
+ // gravityDir: при перевёрнутой гравитации (-1) сила тянет ВВЕРХ
+ // к потолку. Кап-скорости тоже инвертируется.
+ const dashGravityMul = (this._cameraMode === 'sideview') ? 1.35 : 1.0;
+ const userGravityMul = this._gravityMul || 1;
+ const gDir = this._gravityDir || 1;
+
+ if (this._waveMode) {
+ // WAVE-режим: жёстко ±45°. vy = ±autoRunSpeed (тангенс 45° = 1 → |vy| = |vx|).
+ // Гравитация полностью игнорируется — линейное движение по диагонали.
+ const speed = Math.max(1, this._autoRunSpeed || 8);
+ this._vy = this._jumpHeld ? speed : -speed;
+ } else {
+ this._vy += this.GRAVITY * gDir * dashGravityMul * userGravityMul * dt;
+
+ // SHIP-режим: при удержании Space даём импульс ВВЕРХ
+ // (вертолёт-стиль из GD). Гравитация продолжает тянуть, баланс
+ // делает плавный полёт.
+ if (this._shipMode && this._jumpHeld) {
+ // SHIP_THRUST подобран так чтобы при удержании корабль медленно поднимался,
+ // а при отпускании — медленно падал. Зависит от GRAVITY (модуль ~22*1.35*1.227 ≈ 36)
+ const SHIP_THRUST = 80; // м/с² против гравитации
+ this._vy += SHIP_THRUST * gDir * dt;
+ }
+ // ROBOT-режим: пока активна boost-фаза (после прыжка) и Space зажат — компенсируем
+ // почти всю гравитацию, продлевая подъём. Отпустил Space → boost кончается.
+ if (this._robotMode && this._robotBoostLeft > 0) {
+ if (c.has('Space')) {
+ // Компенсация 92% гравитации — игрок продолжает лететь вверх почти линейно.
+ // Это даёт ~3.5-4м высоты при полном удержании 0.45с (хватает на 3-блочную стену).
+ this._vy += -this.GRAVITY * gDir * dashGravityMul * userGravityMul * 0.92 * dt;
+ this._robotBoostLeft = Math.max(0, this._robotBoostLeft - dt);
+ } else {
+ this._robotBoostLeft = 0;
+ }
+ }
+
+ // Cap: ±50 в любом направлении (для Ship — мягче, ±25)
+ const vyCap = this._shipMode ? 25 : 50;
+ if (this._vy < -vyCap) this._vy = -vyCap;
+ if (this._vy > vyCap) this._vy = vyCap;
+ }
+ }
+
+ const beforeX = this._pos.x, beforeZ = this._pos.z;
+
+ // === STICK к движущейся платформе ===
+ // Если в прошлом кадре стояли на примитиве/модели, и она двигается —
+ // двигаем игрока вместе с ней (по дельте позиции платформы за кадр).
+ if (this._lastGroundData && this._lastGroundPos) {
+ const gd = this._lastGroundData;
+ // Текущая позиция платформы — берём из live-data (она обновляется
+ // movingPlatforms-скриптом через scene.move).
+ const curX = gd.x, curY = gd.y, curZ = gd.z;
+ const dPlatX = curX - this._lastGroundPos.x;
+ const dPlatY = curY - this._lastGroundPos.y;
+ const dPlatZ = curZ - this._lastGroundPos.z;
+ // Применяем дельту только если она разумная (защита от телепорта
+ // или dispose'а платформы, когда позиция вдруг становится -50 и т.п.)
+ if (Math.abs(dPlatX) < 5 && Math.abs(dPlatY) < 5 && Math.abs(dPlatZ) < 5) {
+ this._pos.x += dPlatX;
+ this._pos.y += dPlatY;
+ this._pos.z += dPlatZ;
+ }
+ }
+
+ // PERF-METRICS: замер физики игрока
+ const _pt0 = performance.now();
+ const result = this.physics.moveAABB(
+ this._pos, this.HALF_W, this.HALF_H, this.HALF_D,
+ moveX, this._vy * dt, moveZ
+ );
+ const _bs = this._scene3d || this.scene3d;
+ if (_bs && _bs._perfMetrics) {
+ _bs._perfMetrics.physics_ms_sum += performance.now() - _pt0;
+ _bs._perfMetrics.physics_count++;
+ }
+ this._pos.set(result.x, result.y, result.z);
+ if (result.hitY) this._vy = 0;
+ // Surface-follow на smooth-terrain прижал нас к склону — гравитация
+ // больше не нужна на этом кадре, иначе будет вибрация от соревнования
+ // (гравитация тянет вниз → следующий кадр surface поднимает обратно).
+ if (result.surfaceFollowed) this._vy = 0;
+
+ // Auto-step: накапливаем «как будто игрок ещё внизу» оффсет, чтобы
+ // визуально плавно подняться. Физика уже сделала телепорт вверх,
+ // а рендер на несколько кадров отстаёт. Спадание оффсета — ниже
+ // в кадровой логике (раз за tick).
+ if (result.steppedUpBy && result.steppedUpBy > 0) {
+ this._stepUpVisualOffset += result.steppedUpBy;
+ // Кэп — не более 1.2м, иначе при множественных степах подряд
+ // оффсет может стать гигантским и аватар «уйдёт под землю».
+ if (this._stepUpVisualOffset > 1.2) this._stepUpVisualOffset = 1.2;
+ }
+ // Спадание оффсета. dt — реальное время тика (секунды).
+ if (this._stepUpVisualOffset > 0) {
+ this._stepUpVisualOffset -= this._stepUpDecay * dt;
+ if (this._stepUpVisualOffset < 0) this._stepUpVisualOffset = 0;
+ }
+
+ // Запоминаем «на чём стоим» для следующего кадра
+ if (result.onGround && result.groundData?.data) {
+ this._lastGroundData = result.groundData.data;
+ this._lastGroundPos = {
+ x: result.groundData.data.x,
+ y: result.groundData.data.y,
+ z: result.groundData.data.z,
+ };
+ } else {
+ this._lastGroundData = null;
+ this._lastGroundPos = null;
+ }
+
+ // === Авто-вылезание на берег из воды ===
+ // Если игрок в воде, упёрся в стенку (hitX/hitZ) и пытается двигаться —
+ // даём boost вверх чтобы перешагнуть на 1 блок. Имитирует «карабкание».
+ if (inWater && isMoving && (result.hitX || result.hitZ)) {
+ this._vy = Math.max(this._vy, 5);
+ }
+
+ // Респавн если игрок упал в пустоту (за пределы baseplate)
+ if (this._pos.y < -30) {
+ const sp = this._scene3d?._spawnPoint || { x: 0, y: 5, z: 0 };
+ this._pos.set(sp.x, sp.y + this.HALF_H + 0.1, sp.z);
+ this._vy = 0;
+ }
+
+ // === Push unanchored объектов при пересечении ===
+ // Скорость игрока в этом кадре (для направления толчка).
+ // Если игрок упёрся в объект — реальное dx/dz после физики ≈ 0,
+ // поэтому также передаём «желаемое» движение (moveX/moveZ от WASD)
+ // и forward камеры — DynamicsManager выберет лучшее направление.
+ const playerVxReal = (this._pos.x - beforeX) / Math.max(0.0001, dt);
+ const playerVzReal = (this._pos.z - beforeZ) / Math.max(0.0001, dt);
+ const desiredSpeed = Math.sqrt(moveX * moveX + moveZ * moveZ);
+ const realSpeed = Math.sqrt(playerVxReal * playerVxReal + playerVzReal * playerVzReal);
+ // Берём "желаемое" движение если оно больше реального (игрок упёрся,
+ // но WASD нажаты — значит "пытается толкать").
+ const useDesired = desiredSpeed > realSpeed + 0.5;
+ const pushVx = useDesired ? moveX / dt : playerVxReal;
+ const pushVz = useDesired ? moveZ / dt : playerVzReal;
+ if (this._scene3d?.dynamics?.isEnabled?.()) {
+ this._scene3d.dynamics.applyPushFromPlayer(
+ this._pos.x, this._pos.y, this._pos.z,
+ this.HALF_W, this.HALF_H, this.HALF_D,
+ pushVx, pushVz,
+ forward.x, forward.z,
+ playerVxReal, playerVzReal
+ );
+ }
+
+ // === Чекпоинты — обновить точку спавна при касании ===
+ if (this.physics?.getOverlappingPrimitives) {
+ const overlaps = this.physics.getOverlappingPrimitives(
+ this._pos.x, this._pos.y, this._pos.z,
+ this.HALF_W, this.HALF_H, this.HALF_D
+ );
+ for (const data of overlaps) {
+ if (data.type === 'checkpoint' && !this._activatedCheckpoints.has(data.id)) {
+ this._activatedCheckpoints.add(data.id);
+ if (this._scene3d) {
+ // setSpawnPoint поднимет маркер. Координата немного выше пола чекпоинта
+ // чтобы при респавне игрок не попал внутрь чекпоинта.
+ this._scene3d.setSpawnPoint(data.x, data.y + data.sy / 2 + 0.1, data.z);
+ }
+ this._playFootstep(); // звуковой фидбэк (заменим позже на «дзинь»)
+ }
+ }
+ }
+
+ // Шаги — копим пройденную горизонтальную дистанцию когда onGround
+ if (result.onGround && isMoving) {
+ const dxReal = this._pos.x - beforeX;
+ const dzReal = this._pos.z - beforeZ;
+ this._distanceSinceLastStep += Math.sqrt(dxReal * dxReal + dzReal * dzReal);
+ const stepThreshold = isSprinting ? this.STEP_DISTANCE_SPRINT : this.STEP_DISTANCE_WALK;
+ if (this._distanceSinceLastStep >= stepThreshold) {
+ this._distanceSinceLastStep = 0;
+ this._playFootstep();
+ }
+ } else {
+ // В воздухе или стоит — сбрасываем чтобы первый шаг после остановки
+ // не воспроизвёлся слишком рано.
+ this._distanceSinceLastStep = 0;
+ }
+
+ // Coyote-time: после схода с платформы ~0.12 сек ещё можно прыгнуть.
+ // Без этого Dash-таймиги слишком жёсткие — игрок жмёт прыжок чуть позже
+ // края, понимает что упал в яму, бесит. С коянот-окном — прощает.
+ //
+ // gravityDir: при перевёрнутой гравитации (-1) потолок становится «полом»,
+ // используем result.onCeiling вместо onGround.
+ const gDir = this._gravityDir || 1;
+ const effGround = (gDir > 0) ? result.onGround : (result.onCeiling || false);
+ if (effGround) {
+ this._coyoteLeft = 0.12;
+ } else if (this._coyoteLeft > 0) {
+ this._coyoteLeft -= dt;
+ }
+ const canJump = effGround || this._coyoteLeft > 0;
+ // Прыжок только если стоим на земле/потолке (или coyote-окно) и НЕ в воде.
+ // При gDir=-1 прыжок vy<0 (от потолка вниз = «вверх» в перевёрнутой ориентации).
+ // В Ship-режиме обычный jump-импульс отключён — корабль управляется
+ // только удержанием Space (см. блок _shipMode выше). _jumpHeld
+ // обновляем чтобы остальная логика (release, double-jump) работала.
+ if (this._waveMode) {
+ // Wave не использует обычный прыжок — vy уже задано напрямую (см. apply gravity block).
+ // _jumpHeld для синхронизации с состоянием Space.
+ this._jumpHeld = c.has('Space');
+ } else
+ if (canJump && c.has('Space') && !inWater && !this._shipMode && !this._ufoMode) {
+ if (!this._jumpHeld) {
+ // Robot — стартовый импульс полный (как куб) для тапа достаточный,
+ // boost-фаза 0.45с удлиняет подъём при удержании Space.
+ this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
+ this._playJumpSound();
+ this._jumpHeld = true;
+ this._coyoteLeft = 0;
+ // Robot: запускаем boost-фазу на 0.45с
+ if (this._robotMode) {
+ this._robotBoostLeft = 0.45;
+ }
+ }
+ } else if (this._shipMode && c.has('Space')) {
+ this._jumpHeld = true;
+ } else if (this._ufoMode && c.has('Space') && !inWater) {
+ // UFO: каждый отдельный тап = микропрыжок (даже в воздухе).
+ // Используем _jumpHeld чтобы избежать повторного срабатывания пока кнопка зажата.
+ if (!this._jumpHeld) {
+ this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir * 0.85;
+ this._playJumpSound();
+ this._jumpHeld = true;
+ }
+ }
+ // Сбрасываем флаг "второй прыжок использован" при касании земли/потолка
+ if (effGround) this._doubleJumpUsed = false;
+ // Двойной прыжок: в воздухе и Space нажат отдельно (после release).
+ if (!effGround && !inWater && c.has('Space')
+ && this._doubleJumpEnabled && !this._doubleJumpUsed && !this._jumpHeld) {
+ this._vy = this.JUMP_VELOCITY * (this._jumpPowerMul || 1) * gDir;
+ this._playJumpSound();
+ this._doubleJumpUsed = true;
+ this._jumpHeld = true;
+ }
+ if (!c.has('Space')) this._jumpHeld = false;
+
+ // Звук всплеска при входе/выходе из воды
+ if (this._wasInWater !== inWater) {
+ this._playSplashSound();
+ this._wasInWater = inWater;
+ }
+
+ // Состояние игрока для game.player.state ('ground'|'air'|'water').
+ this._playerState = inWater ? 'water' : (effGround ? 'ground' : 'air');
+ // Хук game.onPlayerLand — был в воздухе, коснулся земли.
+ if (effGround && this._wasOnGround === false && !inWater) {
+ if (typeof this._onLand === 'function') {
+ try { this._onLand(); } catch (e) { /* ignore */ }
+ }
+ }
+ this._wasOnGround = effGround;
+
+ // === Камера ===
+ this.camera.position = this._computeCameraPos();
+ // Управление камерой из скрипта (Фаза 5.7) — перебивает обычную.
+ if (this._cameraOverride) {
+ this._applyCameraOverride(dt);
+ }
+ // Camera shake — небольшое случайное смещение по X/Y, затухает.
+ if (this._cameraShakeLeft > 0) {
+ this._cameraShakeLeft -= dt;
+ const t = Math.max(0, this._cameraShakeLeft);
+ const amp = this._cameraShakeAmp * Math.min(1, t * 4); // быстрая ослабка
+ this.camera.position.x += (Math.random() - 0.5) * amp;
+ this.camera.position.y += (Math.random() - 0.5) * amp;
+ }
+ // Скрипт-управление камерой (cutscene/focus) уже выставило И
+ // позицию, И направление взгляда через setTarget. НЕ трогаем
+ // camera.rotation — иначе setTarget затирается и катсцена
+ // «смотрит прямо» вместо вращения по точкам lookAt.
+ if (!this._cameraOverride) {
+ if (this._cameraMode === 'front') {
+ // Камера смотрит назад на игрока — yaw на 180°, pitch инверт.
+ this.camera.rotation.x = -this._pitch;
+ this.camera.rotation.y = this._yaw + Math.PI;
+ } else if (this._cameraMode === 'sideview') {
+ // Sideview: камера в -Z от игрока, смотрит на +Z.
+ this.camera.rotation.x = 0;
+ this.camera.rotation.y = 0;
+ this.camera.rotation.z = 0;
+ } else {
+ this.camera.rotation.x = this._pitch;
+ this.camera.rotation.y = this._yaw;
+ }
+ if (this._cameraMode !== 'sideview') this.camera.rotation.z = 0;
+ }
+
+ // === Модель игрока ===
+ if (this._modelRoot) {
+ // === Поза: в воде персонаж лежит горизонтально (плавает) ===
+ // Цель наклона — 0 на суше, π/2 в воде. Плавная интерполяция.
+ const targetSwimTilt = inWater ? Math.PI / 2 : 0;
+ const swimTilt = this._swimTilt ?? 0;
+ const tiltStep = 4 * dt; // 4 рад/с скорость наклона
+ let nextTilt = swimTilt;
+ if (Math.abs(targetSwimTilt - swimTilt) <= tiltStep) {
+ nextTilt = targetSwimTilt;
+ } else {
+ nextTilt += Math.sign(targetSwimTilt - swimTilt) * tiltStep;
+ }
+ this._swimTilt = nextTilt;
+
+ // В воде наклон 90° кладёт модель горизонтально. yLift поднимает
+ // root к центру AABB. Также при наклоне корень оказывается в позиции
+ // ног (которые сзади головы при этом ракурсе), поэтому сдвигаем root
+ // на длину модели вперёд по yaw чтобы голова была впереди.
+ const tiltFrac = nextTilt / (Math.PI / 2); // 0..1
+ const yLift = inWater ? this.HALF_H * tiltFrac : 0;
+ const bodyLen = this.HALF_H * 2 * 0.7; // примерная длина тела
+ const fwdShift = inWater ? bodyLen * tiltFrac : 0;
+ const fx = Math.sin(this._modelYaw);
+ const fz = Math.cos(this._modelYaw);
+ this._modelRoot.position.set(
+ this._pos.x + fx * fwdShift,
+ this._pos.y - this.HALF_H + yLift - this._stepUpVisualOffset,
+ this._pos.z + fz * fwdShift
+ );
+
+ // Поворот модели:
+ // - на суше: направление РЕАЛЬНОГО движения (как было).
+ // - в воде: направление КАМЕРЫ (yaw игрока). Стрейф A/D просто
+ // двигает тело вбок без вращения, как на суше при first-person.
+ if (inWater) {
+ const targetYaw = this._yaw;
+ let diff = targetYaw - this._modelYaw;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ const maxStep = this.MODEL_TURN_SPEED * dt * 2;
+ if (Math.abs(diff) <= maxStep) {
+ this._modelYaw = targetYaw;
+ } else {
+ this._modelYaw += Math.sign(diff) * maxStep;
+ }
+ } else {
+ // Roblox-style: в first/lockfirst/shiftLock корпус мгновенно
+ // следует за yaw камеры (AutoRotate привязан к камере).
+ // В third — корпус доворачивается под РЕАЛЬНОЕ направление движения.
+ const followCamera = (
+ this._cameraMode === 'first' ||
+ this._cameraMode === 'lockfirst' ||
+ this._shiftLock
+ );
+ if (followCamera) {
+ const targetYaw = this._yaw;
+ let diff = targetYaw - this._modelYaw;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ const maxStep = this.MODEL_TURN_SPEED * dt * 3; // быстрее чем при ходьбе
+ if (Math.abs(diff) <= maxStep) {
+ this._modelYaw = targetYaw;
+ } else {
+ this._modelYaw += Math.sign(diff) * maxStep;
+ }
+ } else {
+ const dxReal = this._pos.x - beforeX;
+ const dzReal = this._pos.z - beforeZ;
+ const movedHorizontal = Math.abs(dxReal) > 0.001 || Math.abs(dzReal) > 0.001;
+ if (movedHorizontal) {
+ const targetYaw = Math.atan2(dxReal, dzReal);
+ let diff = targetYaw - this._modelYaw;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ const maxStep = this.MODEL_TURN_SPEED * dt;
+ if (Math.abs(diff) <= maxStep) {
+ this._modelYaw = targetYaw;
+ } else {
+ this._modelYaw += Math.sign(diff) * maxStep;
+ }
+ }
+ }
+ }
+ // Применяем yaw + swim-tilt.
+ // rotation.x = +π/2 кладёт модель лицом вниз; при этом голова уходит
+ // НАЗАД относительно корня — компенсируем сдвигом root вперёд (см. fwdShift).
+ this._modelRoot.rotation.y = this._modelYaw;
+ this._modelRoot.rotation.x = nextTilt;
+ // В воде также добавляем лёгкое покачивание по Z (как волна тела)
+ if (inWater) {
+ const wobble = Math.sin((this._scene3d?.engine?.getDeltaTime?.() || 0) * 0.001 + performance.now() * 0.004) * 0.15;
+ this._modelRoot.rotation.z = wobble;
+ } else if (this._cameraMode === 'sideview') {
+ // Кубикон Dash: в воздухе куб крутится назад (по часовой если
+ // смотреть с -Z), на земле плавно возвращается в 0.
+ // Скорость подобрана так чтобы между прыжками куб успевал
+ // совершить ~1 оборот.
+ const SPIN_SPEED = Math.PI * 1.8; // ~1.8π рад/с
+ if (!result.onGround) {
+ this._dashSpinAngle -= SPIN_SPEED * dt;
+ } else {
+ // Дотягиваем до ближайшего кратного 2π (т.е. визуально 0)
+ const TAU = Math.PI * 2;
+ const target = Math.round(this._dashSpinAngle / TAU) * TAU;
+ const diff = target - this._dashSpinAngle;
+ const snapStep = SPIN_SPEED * 1.5 * dt;
+ if (Math.abs(diff) <= snapStep) this._dashSpinAngle = target;
+ else this._dashSpinAngle += Math.sign(diff) * snapStep;
+ }
+ this._modelRoot.rotation.z = this._dashSpinAngle;
+ } else {
+ this._modelRoot.rotation.z = 0;
+ }
+ // Поза с оружием — обновляем флаг каждый кадр (на случай смены)
+ const hasWeapon = !!this._scene3d?.weapons?._equipped;
+ this._updateExtendedArm(hasWeapon);
+ // Применяем все override'ы вращения мешей ПОСЛЕ всех манипуляций.
+ // Анимация Kenney пишет в rotationQuaternion на меше — обнуляем
+ // и пишем свои углы Эйлера. Это ключевой момент: вызывается
+ // каждый кадр, поэтому override переживает анимацию.
+ this._applyMeshRotationOverrides();
+ }
+
+ // Кубикон Dash: скрипт мог попросить скрыть скин (game.player.setSkinVisible(false)).
+ // Применяем каждый кадр — на случай если меши только что асинхронно
+ // загрузились, либо _applyCameraMode перезаписал enabled=true.
+ if (!this._skinVisibleScripted && this._modelMeshes && this._modelMeshes.length > 0) {
+ for (let i = 0; i < this._modelMeshes.length; i++) {
+ const m = this._modelMeshes[i];
+ if (m && m.isEnabled && m.isEnabled() && m.setEnabled) {
+ try { m.setEnabled(false); } catch (e) {}
+ }
+ }
+ }
+
+ // Тик распадающихся кусков игрока (после смерти)
+ this._tickDebris(dt);
+
+ // === Анимации ===
+ // Снимок скорости/опоры для процедурной анимации non-humanoid скинов.
+ this._lastFrameSpeed = (isMoving ? (isSprinting ? this.WALK_SPEED * this.SPRINT_MULT : this.WALK_SPEED) : 0) * (this._speedMul || 1);
+ this._isGrounded = !!result.onGround;
+
+ // Non-humanoid single-mesh скин: костей нет — анимируем процедурно
+ // (покачивание/наклон). Делаем ДО R15-ветки, т.к. _isR15=false для них.
+ if (this._modelKind === 'non-humanoid-mesh' && this._modelRoot) {
+ this._animateNonHumanoidMesh(dt);
+ return;
+ }
+
+ // R15-скин: процедурный аниматор (нет glTF AnimationGroups).
+ // Состояния: idle/walk/run/jump/fall. sprint → run.
+ if (this._isR15 && this._r15Animator) {
+ let r15State;
+ if (!result.onGround) {
+ // vy > 0 — вверх (jump-поза с поджатыми ногами),
+ // vy < 0 — вниз (fall, лёгкий наклон корпуса).
+ r15State = (this._vy > 0.5) ? 'jump' : 'fall';
+ } else if (inWater) {
+ r15State = isMoving ? 'walk' : 'idle';
+ } else if (isMoving) {
+ r15State = isSprinting ? 'run' : 'walk';
+ } else {
+ r15State = 'idle';
+ }
+ this._r15Animator.setState(r15State);
+ this._r15Animator.update(dt);
+ // Override костей поверх анимации (поза руки с оружием/замах).
+ this._applyR15BoneOverrides();
+ return; // R15 не использует AnimationGroups-путь ниже
+ }
+
+ let nextAnim;
+ if (inWater) {
+ // В воде — walk-анимация выглядит как гребки/педаляж в горизонтальной позе
+ nextAnim = isMoving ? 'walk' : 'idle';
+ } else if (!result.onGround) {
+ nextAnim = this._animations.jump ? 'jump' : (isMoving ? 'walk' : 'idle');
+ } else if (isMoving) {
+ nextAnim = isSprinting ? 'sprint' : 'walk';
+ } else {
+ nextAnim = 'idle';
+ }
+ // Снимок состояния для лога в _playAnim
+ this._lastAnimDebug = {
+ vy: this._vy,
+ og: !!result.onGround,
+ sf: !!result.surfaceFollowed,
+ mv: !!isMoving,
+ };
+ // === Периодический trace состояния (раз в 30 кадров ~0.5с) ===
+ // Видим как меняются onGround/surfaceFollowed/vy даже когда анимация
+ // не меняется — полезно для разбора "вибрации".
+ if (!this._animTraceCnt) this._animTraceCnt = 0;
+ this._animTraceCnt++;
+ if (this._animTraceCnt >= 30) {
+ this._animTraceCnt = 0;
+ const d = this._lastAnimDebug;
+ console.log(`[AnimTrace] anim=${this._currentAnim} `
+ + `og=${d.og} sf=${d.sf} mv=${d.mv} vy=${d.vy.toFixed(2)}`);
+ }
+ this._playAnim(nextAnim);
+ }
+}
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index b82d650..a2afaba 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -79,6 +79,8 @@ let _playerJoinHandlers = [];
let _playerLeaveHandlers = [];
// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7).
let _cutsceneDoneHandlers = [];
+let _vehicleEnterHandlers = []; // задача 14
+let _vehicleExitHandlers = [];
let _mpMessageHandlers = {}; // name → [fn]
let _remoteHandlers = {}; // Phase 6.6: RemoteEvent handlers, name → [fn]
// Подписки game.room.onChange(key, fn): key → [fn].
@@ -109,7 +111,7 @@ let _invUiSlotClickHandlers = [];
const _instTouchHandlers = new Map();
function _instHandlerBucket(ref) {
let b = _instTouchHandlers.get(ref);
- if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); }
+ if (!b) { b = { touch: [], untouch: [], click: [], interact: [] }; _instTouchHandlers.set(ref, b); }
return b;
}
// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot')
@@ -512,6 +514,18 @@ function _getOrCreateInstance(ref, kindHint) {
_instHandlerBucket(ref).click.push(fn);
_send('inst.watchClick', { ref });
};
+ // Задача 14: findOne(ref).onInteract(fn, {text,distance,key,holdDuration})
+ if (prop === 'onInteract') return (fn, opts) => {
+ if (typeof fn !== 'function') return;
+ _instHandlerBucket(ref).interact.push(fn);
+ _send('inst.registerInteract', {
+ ref,
+ text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать',
+ distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4,
+ key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e',
+ holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
+ });
+ };
return undefined;
},
@@ -718,6 +732,8 @@ function _buildSelfApi() {
text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать',
distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4,
key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e',
+ // Задача 14: hold-to-action — сколько секунд держать клавишу (0=мгновенно).
+ holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
});
},
move(x, y, z) {
@@ -1344,6 +1360,14 @@ const game = {
onCutsceneDone(fn) {
if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn);
},
+ /** Задача 14: игрок сел в машину. fn(vehicleRef). */
+ onVehicleEnter(fn) {
+ if (typeof fn === 'function') _vehicleEnterHandlers.push(fn);
+ },
+ /** Задача 14: игрок вышел из машины. fn(vehicleRef). */
+ onVehicleExit(fn) {
+ if (typeof fn === 'function') _vehicleExitHandlers.push(fn);
+ },
/** Игрок покинул комнату. fn({sessionId, name}). */
onPlayerLeave(fn) {
if (typeof fn === 'function') _playerLeaveHandlers.push(fn);
@@ -1584,6 +1608,18 @@ const game = {
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
return ref;
}
+ // Задача 14: машина. type = 'vehicle:car' (subType сейчас не важен).
+ if (kind === 'vehicle') {
+ _localRefSeq++;
+ const ref = 'vehicle:_local_' + _localRefSeq;
+ _send('scene.spawn', {
+ kind: 'vehicle', subType,
+ model: opts.model || 'car-sedan', color: opts.color, name: opts.name,
+ params: opts.params || {},
+ x, y, z, rotationY: opts.rotationY || 0, ref,
+ });
+ return _getOrCreateInstance(ref, 'vehicle') || ref;
+ }
return null;
},
/** Удалить объект по ref. */
@@ -4191,6 +4227,10 @@ self.onmessage = (e) => {
const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click;
for (const fn of list) _safeCall(fn, payload, 'inst.' + t);
}
+ } else if (t === 'instInteract') {
+ // Задача 14: взаимодействие (F) с объектом по findOne(ref).onInteract.
+ const b = _instTouchHandlers.get(payload && payload.ref);
+ if (b) for (const fn of b.interact) _safeCall(fn, payload, 'inst.onInteract');
} else if (t === 'hpChange') {
for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange');
} else if (t === 'mobKilled') {
@@ -4238,6 +4278,13 @@ self.onmessage = (e) => {
} else if (t === 'cutsceneDone') {
// Катсцена камеры завершилась (Фаза 5.7).
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
+ } else if (t === 'vehicleEnter') {
+ // Задача 14: игрок сел в машину. payload: { vehicleId }.
+ const vref = 'vehicle:' + (payload && payload.vehicleId);
+ for (const fn of _vehicleEnterHandlers) _safeCall(fn, vref, 'onVehicleEnter');
+ } else if (t === 'vehicleExit') {
+ const vref = 'vehicle:' + (payload && payload.vehicleId);
+ for (const fn of _vehicleExitHandlers) _safeCall(fn, vref, 'onVehicleExit');
} else if (t === 'playerJoin') {
// payload: { sessionId, name }
for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin');
diff --git a/src/editor/engine/VehicleHud.js b/src/editor/engine/VehicleHud.js
new file mode 100644
index 0000000..adcaf0f
--- /dev/null
+++ b/src/editor/engine/VehicleHud.js
@@ -0,0 +1,95 @@
+/**
+ * VehicleHud — HUD водителя (задача 14): круглый спидометр со стрелкой,
+ * передача (D/R/N), подсказки клавиш. DOM-оверлей поверх canvas (как
+ * ShopInventoryUi). Показывается пока игрок за рулём.
+ *
+ * Фича-парность: идентичный модуль в rublox-player/src/engine/.
+ */
+export class VehicleHud {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this.root = null;
+ this.needle = null;
+ this.speedText = null;
+ this.gearText = null;
+ this._maxKmh = 80;
+ }
+
+ show(maxKmh) {
+ this.remove();
+ this._maxKmh = Math.max(20, Math.round((maxKmh || 14) * 3.6 / 10) * 10 + 10);
+ const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
+ try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
+
+ const root = document.createElement('div');
+ root.className = 'kbn-veh-hud';
+ root.style.cssText =
+ 'position:absolute;left:24px;bottom:22px;z-index:45;width:160px;height:160px;' +
+ 'pointer-events:none;font-family:system-ui,"Segoe UI",sans-serif;user-select:none;';
+
+ // SVG-циферблат.
+ const R = 70, CX = 80, CY = 80;
+ const startA = 135, endA = 405; // дуга 270°
+ const ticks = [];
+ const N = 8;
+ for (let i = 0; i <= N; i++) {
+ const a = (startA + (endA - startA) * i / N) * Math.PI / 180;
+ const x1 = CX + Math.cos(a) * (R - 4), y1 = CY + Math.sin(a) * (R - 4);
+ const x2 = CX + Math.cos(a) * (R - 14), y2 = CY + Math.sin(a) * (R - 14);
+ ticks.push(``);
+ const lx = CX + Math.cos(a) * (R - 26), ly = CY + Math.sin(a) * (R - 26) + 4;
+ const val = Math.round(this._maxKmh * i / N);
+ ticks.push(`${val}`);
+ }
+ root.innerHTML =
+ ``;
+ parent.appendChild(root);
+ this.root = root;
+ this.needle = root.querySelector('#kbn-veh-needle');
+ this.speedText = root.querySelector('#kbn-veh-speed');
+ this.gearText = root.querySelector('#kbn-veh-gear');
+ this._CX = CX; this._CY = CY;
+
+ // Подсказки клавиш справа снизу.
+ const keys = document.createElement('div');
+ keys.className = 'kbn-veh-keys';
+ keys.style.cssText =
+ 'position:absolute;right:24px;bottom:28px;z-index:45;pointer-events:none;' +
+ 'color:#cfd6e0;font:600 14px/1.6 system-ui,sans-serif;text-align:right;' +
+ 'text-shadow:0 1px 3px rgba(0,0,0,0.7);';
+ keys.innerHTML = '
WASD — руль
V — камера
E — выйти
';
+ parent.appendChild(keys);
+ this._keys = keys;
+ }
+
+ /** Обновить стрелку/число/передачу. speed — м/с (signed). */
+ update(speedMs) {
+ if (!this.needle) return;
+ const kmh = Math.abs(speedMs) * 3.6;
+ const frac = Math.max(0, Math.min(1, kmh / this._maxKmh));
+ const ang = -135 + 270 * frac; // -135°..+135°
+ this.needle.setAttribute('transform', `rotate(${ang.toFixed(1)} ${this._CX} ${this._CY})`);
+ if (this.speedText) this.speedText.textContent = String(Math.round(kmh));
+ if (this.gearText) {
+ const g = speedMs < -0.3 ? 'R' : (Math.abs(speedMs) < 0.3 ? 'N' : 'D');
+ this.gearText.textContent = g;
+ this.gearText.setAttribute('fill', g === 'R' ? '#ff7a5a' : g === 'N' ? '#9aa6b8' : '#7fe0a0');
+ }
+ }
+
+ remove() {
+ if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
+ if (this._keys) { try { this._keys.remove(); } catch { /* ignore */ } this._keys = null; }
+ this.needle = this.speedText = this.gearText = null;
+ }
+
+ dispose() { this.remove(); }
+}
diff --git a/src/editor/engine/VehicleManager.js b/src/editor/engine/VehicleManager.js
new file mode 100644
index 0000000..aabbf04
--- /dev/null
+++ b/src/editor/engine/VehicleManager.js
@@ -0,0 +1,251 @@
+import { Vector3, TransformNode } from '@babylonjs/core';
+
+/**
+ * VehicleManager — система транспорта (задача 14, фаза V1 аркадная + V2 параметры).
+ *
+ * Каждая машина = chassisNode (TransformNode) + GLB-кузов (modelManager-инстанс) +
+ * 4 колеса-визуала (передние доворачивают при руле). Физика АРКАДНАЯ:
+ * speed (скаляр вдоль yaw) += throttle*power*dt; трение; поворот по steer
+ * (масштаб от скорости — нет вращения на месте); коллизия с миром через
+ * physics.moveAABB (тот же солвер что у игрока). Колёса друг с другом и с
+ * другими машинами НЕ сталкиваются (V1) — только chassis с миром.
+ *
+ * Фича-парность: идентичный модуль в rublox-player/src/engine/.
+ */
+
+const DEFAULT_PARAMS = {
+ mass: 1200,
+ enginePower: 14, // ускорение (м/с²) — аркадно, не реальные л.с.
+ maxSpeed: 14, // м/с (~50 км/ч) — для маленьких миров
+ turnSpeed: 1.8, // рад/с при полной скорости
+ brake: 26, // замедление при тормозе/реверсе
+ drive: 'rwd',
+};
+
+export class VehicleManager {
+ constructor(scene3d) {
+ this.s = scene3d;
+ this.scene = scene3d.scene;
+ this.vehicles = new Map(); // id → veh
+ this._seq = 0;
+ }
+
+ get _physics() { return this.s.physics; }
+ get _models() { return this.s.modelManager; }
+
+ /**
+ * Создать машину. opts: { model:'car-taxi', color, name, params, x,y,z, rotationY }.
+ * Возвращает Promise.
+ */
+ async spawn(opts) {
+ opts = opts || {};
+ const x0 = Number(opts.x) || 0, z0 = Number(opts.z) || 0;
+ // Идемпотентность: если машина с такой позицией уже есть — не плодим
+ // (защита от двойного выполнения скрипта спавна → дубли машин).
+ for (const v of this.vehicles.values()) {
+ if (Math.abs(v.spawnX - x0) < 0.5 && Math.abs(v.spawnZ - z0) < 0.5) return v.id;
+ }
+ const id = ++this._seq;
+ const x = Number(opts.x) || 0, y = Number(opts.y) || 0.4, z = Number(opts.z) || 0;
+ const yaw = Number(opts.rotationY) || 0;
+ const params = { ...DEFAULT_PARAMS, ...(opts.params || {}) };
+ const modelType = opts.model || 'car-sedan';
+
+ // chassis-узел — родитель кузова и колёс.
+ const chassisNode = new TransformNode(`vehicle_${id}`, this.scene);
+ chassisNode.position = new Vector3(x, y, z);
+ chassisNode.rotation = new Vector3(0, yaw, 0);
+
+ const veh = {
+ id, name: opts.name || 'Машина', params,
+ spawnX: x, spawnZ: z, // для дедупа повторного спавна
+ chassisNode, bodyInstanceId: null, wheels: [],
+ pos: new Vector3(x, y, z), yaw, vy: 0,
+ speed: 0, steerAngle: 0,
+ half: { w: 1.0, h: 0.6, d: 2.0 }, // уточним по bbox кузова
+ throttle: 0, steer: 0, handbrake: false,
+ driver: null,
+ handlers: { onEnter: [], onExit: [], onCollide: [], onSpeedChange: [] },
+ ref: opts.ref || null,
+ };
+ this.vehicles.set(id, veh);
+
+ // Кузов (GLB Kenney car-kit).
+ try {
+ const bodyId = await this._models.addInstance(modelType, x, y, z, yaw);
+ veh.bodyInstanceId = bodyId;
+ const inst = this._models.instances.get(bodyId);
+ if (inst && inst.rootMesh) {
+ // Габариты AABB + вертикальный offset кузова СЧИТАЕМ ДО парентинга
+ // (в мировых координатах, кузов ещё в (x,y,z)).
+ try {
+ const bb = inst.rootMesh.getHierarchyBoundingVectors(true);
+ veh.half = {
+ w: Math.max(0.6, (bb.max.x - bb.min.x) / 2),
+ h: Math.max(0.4, (bb.max.y - bb.min.y) / 2),
+ d: Math.max(1.0, (bb.max.z - bb.min.z) / 2),
+ };
+ // Насколько низ кузова ниже точки спавна y — чтобы посадить
+ // кузов так, чтобы его НИЗ совпал с низом AABB (машина на земле,
+ // не парит). bodyYOffset применяется к локальной Y кузова.
+ veh.bodyYOffset = -(bb.min.y - y) - veh.half.h;
+ } catch (e) { veh.bodyYOffset = -veh.half.h; }
+ inst.rootMesh.setParent(chassisNode);
+ inst.rootMesh.position = new Vector3(0, veh.bodyYOffset || 0, 0);
+ inst.rootMesh.rotation = Vector3.Zero();
+ // Цвет кузова (tint поверх GLB-текстуры).
+ if (opts.color) { try { this._models.setInstanceProps?.(bodyId, { tint: opts.color }); } catch (e) {} }
+ }
+ } catch (e) { console.warn('[VehicleManager] body load failed', e); }
+
+ // Колёса НЕ спавним отдельно — GLB-модели Kenney car-kit уже содержат
+ // колёса в кузове. Отдельные колёса дублировали/отрывались (баг V1).
+ // Визуальный доворот передних колёс — фаза V3 (там кузов+колёса раздельно).
+
+ // «Оседание»: уроним машину на землю СРАЗУ (до посадки игрока), иначе она
+ // висит/утоплена на стартовой y, пока никто не за рулём (нет tick).
+ this._settle(veh);
+ // Повторное оседание на следующих кадрах: физический грид статики может
+ // ещё не проиндексироваться к моменту спавна (await addInstance), тогда
+ // первый _settle не находит пол и машина зависает в воздухе (баг седана).
+ for (const d of [120, 350, 800]) {
+ setTimeout(() => { try { if (!veh.driver) this._settle(veh); } catch (e) {} }, d);
+ }
+
+ return id;
+ }
+
+ /**
+ * Опустить машину на поверхность гравитацией. Стартуем ВЫШЕ текущей точки и
+ * роняем большим запасом (много шагов), чтобы гарантированно найти пол даже
+ * если стартовая y оказалась чуть ниже/выше или физика поздно готова.
+ */
+ _settle(veh) {
+ try {
+ // Поднимем чуть вверх и роняем с запасом (до ~20 ед. вниз).
+ veh.pos.y += 0.5;
+ let landed = false;
+ for (let i = 0; i < 80; i++) {
+ const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.25, 0);
+ veh.pos.set(r.x, r.y, r.z);
+ if (r.hitY) { landed = true; break; }
+ }
+ // Микро-дожатие: пока земля прямо под низом — подвинуть вплотную.
+ if (landed) {
+ for (let i = 0; i < 4; i++) {
+ const r = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, 0, -0.04, 0);
+ veh.pos.set(r.x, r.y, r.z);
+ if (r.hitY) break;
+ }
+ }
+ veh.vy = 0;
+ veh.chassisNode.position.copyFrom(veh.pos);
+ veh.chassisNode.rotation.y = veh.yaw;
+ } catch (e) { /* ignore */ }
+ }
+
+ getById(id) { return this.vehicles.get(id) || null; }
+
+ /** Установить ввод водителя (из PlayerController). */
+ setInput(veh, throttle, steer, handbrake) {
+ if (!veh) return;
+ veh.throttle = Math.max(-1, Math.min(1, throttle || 0));
+ veh.steer = Math.max(-1, Math.min(1, steer || 0));
+ veh.handbrake = !!handbrake;
+ }
+
+ /** Физический шаг машины (вызывается каждый кадр пока есть водитель). */
+ tickVehicle(veh, dt) {
+ if (!veh) return;
+ dt = Math.min(dt, 1 / 30);
+ const p = veh.params;
+ const prevSpeed = veh.speed;
+
+ // Ускорение / торможение / реверс.
+ if (veh.throttle > 0) {
+ veh.speed += veh.throttle * p.enginePower * dt;
+ } else if (veh.throttle < 0) {
+ // S: сначала тормоз, потом задний ход (ограничен).
+ if (veh.speed > 0.2) veh.speed -= p.brake * dt;
+ else veh.speed += veh.throttle * p.enginePower * 0.5 * dt;
+ }
+ // Накат-трение.
+ veh.speed *= (1 - 1.2 * dt);
+ if (veh.handbrake) veh.speed *= (1 - 6 * dt);
+ // Клампы.
+ const maxFwd = p.maxSpeed, maxRev = p.maxSpeed * 0.4;
+ if (veh.speed > maxFwd) veh.speed = maxFwd;
+ if (veh.speed < -maxRev) veh.speed = -maxRev;
+ if (Math.abs(veh.speed) < 0.05) veh.speed = 0;
+
+ // Поворот (зависит от скорости — нельзя крутиться на месте).
+ const speedFrac = veh.speed / maxFwd;
+ veh.yaw += veh.steer * p.turnSpeed * speedFrac * dt;
+ // Угол доворота передних колёс (визуал) — плавный lerp.
+ const targetSteer = veh.steer * 0.5;
+ veh.steerAngle += (targetSteer - veh.steerAngle) * Math.min(1, dt * 8);
+
+ // Направление и перемещение.
+ const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
+ const moveX = dir.x * veh.speed * dt;
+ const moveZ = dir.z * veh.speed * dt;
+ // Гравитация (машина сидит на полу/дороге).
+ veh.vy += -22 * dt;
+
+ // Коллизия с миром через тот же солвер что у игрока.
+ let res;
+ try {
+ res = this._physics.moveAABB(veh.pos, veh.half.w, veh.half.h, veh.half.d, moveX, veh.vy * dt, moveZ);
+ } catch (e) {
+ res = { x: veh.pos.x + moveX, y: veh.pos.y, z: veh.pos.z + moveZ, hitX: false, hitY: false, hitZ: false };
+ }
+ veh.pos.set(res.x, res.y, res.z);
+ if (res.hitY) veh.vy = 0;
+ // Удар об стену — гасим ход.
+ if (res.hitX || res.hitZ) {
+ const force = Math.abs(veh.speed);
+ veh.speed *= 0.3;
+ for (const fn of veh.handlers.onCollide) { try { fn(force); } catch (e) {} }
+ }
+
+ // Применить к узлам.
+ veh.chassisNode.position.copyFrom(veh.pos);
+ veh.chassisNode.rotation.y = veh.yaw;
+ // Колёса: передние доворачивают, все катятся.
+ const roll = (veh.speed * dt) / 0.4;
+ for (const w of veh.wheels) {
+ if (w.isFront) w.node.rotation.y = veh.steerAngle;
+ w.node.rotation.x = (w.node.rotation.x + roll) % (Math.PI * 2);
+ }
+
+ if (Math.abs(veh.speed - prevSpeed) > 0.01) {
+ for (const fn of veh.handlers.onSpeedChange) { try { fn(Math.abs(veh.speed)); } catch (e) {} }
+ }
+ // Падение в бездну — сигнал PlayerController высадить + респавн.
+ if (veh.pos.y < -25) return { fellOut: true };
+ return null;
+ }
+
+ /** Текущая скорость машины в м/с (для спидометра). */
+ speedOf(veh) { return veh ? Math.abs(veh.speed) : 0; }
+
+ applyImpulse(veh, v) {
+ if (!veh || !v) return;
+ // Простой импульс: вертикальная составляющая в vy, горизонтальная в speed по направлению.
+ if (Number.isFinite(v.y)) veh.vy += Number(v.y);
+ const dir = new Vector3(Math.sin(veh.yaw), 0, Math.cos(veh.yaw));
+ const horiz = (Number(v.x) || 0) * dir.x + (Number(v.z) || 0) * dir.z;
+ veh.speed += horiz;
+ }
+
+ dispose() {
+ for (const veh of this.vehicles.values()) {
+ try {
+ if (veh.bodyInstanceId != null) this._models.removeInstance?.(veh.bodyInstanceId);
+ for (const w of veh.wheels) this._models.removeInstance?.(w.instanceId);
+ veh.chassisNode?.dispose?.();
+ } catch (e) { /* ignore */ }
+ }
+ this.vehicles.clear();
+ }
+}
diff --git a/src/preview-player/KubikonPlayer.jsx b/src/preview-player/KubikonPlayer.jsx
index 58ba7fb..4b85d34 100644
--- a/src/preview-player/KubikonPlayer.jsx
+++ b/src/preview-player/KubikonPlayer.jsx
@@ -465,8 +465,9 @@ const KubikonPlayer = () => {
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoading(false);
- // Засчитываем плей
- Kubikon3DApi.incrementPlay(projectId).catch(() => {});
+ // Засчитываем плей. Передаём user_id (если залогинен) —
+ // активирует self-cooldown и user-cooldown на бэке.
+ Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
// Запускаем игру сразу
setTimeout(() => {
scene.enterPlayMode?.();