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