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(); } }