feat(14): Vehicle System V1+V2 � ������, �� ������� ����� ������ #26
@ -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: повторный голос того же типа снимает,
|
||||
* голос другого типа — переключает. */
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -8485,6 +8485,117 @@ step();`}</Code>
|
||||
),
|
||||
},
|
||||
|
||||
'guide-taxisim': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
<b>Настоящие машины, на которых можно ездить.</b> Подходишь к
|
||||
автомобилю → над ним появляется подсказка <b>«[F] Enter»</b>, держишь
|
||||
F → садишься за руль. <b>WASD</b> рулят (газ/тормоз/задний ход +
|
||||
повороты), камера плавно <b>следует за машиной</b>, снизу — <b>спидометр</b>
|
||||
с передачей (D/R/N). <b>V</b> меняет ракурс камеры, <b>E</b> — выйти.
|
||||
Машина сталкивается со стенами и столбами. Это основа таксопарков,
|
||||
гонок, доставки — 30% жанров Roblox держатся на транспорте.
|
||||
</p>
|
||||
|
||||
<Shot src="guide-taxisim-play.png" wide
|
||||
caption="Игрок за рулём такси: камера за машиной, спидометр снизу, подсказки клавиш. Рядом — второй автомобиль (готовые 3D-модели)." />
|
||||
|
||||
<h3 className="lessonH">Чему научишься</h3>
|
||||
<ul>
|
||||
<li><b>game.scene.spawn('vehicle:car', opts)</b> — создать машину:
|
||||
модель, цвет, имя (в подсказке), параметры (скорость/руль/тормоз);</li>
|
||||
<li><b>вход/выход</b> — у машины автоматически появляется сиденье:
|
||||
подошёл → держишь F → за рулём; E — выйти;</li>
|
||||
<li><b>аркадная физика</b> — газ/тормоз/реверс + повороты (на скорости),
|
||||
столкновения с миром, передние колёса доворачивают;</li>
|
||||
<li><b>камера машины</b> — следует за авто; V циклит follow / капот /
|
||||
кинематографичный ракурс;</li>
|
||||
<li><b>HUD водителя</b> — спидометр (км/ч) и передача появляются сами;</li>
|
||||
<li><b>onVehicleEnter / onVehicleExit</b> — события для логики (квесты,
|
||||
деньги за поездку).</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Заспавнить машину</h3>
|
||||
<p>
|
||||
Одна строка создаёт готовый к езде автомобиль. <code>model</code> —
|
||||
одна из встроенных 3D-моделей (car-taxi / car-sedan / car-truck /
|
||||
car-suv-luxury и др.), <code>name</code> показывается в подсказке F.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`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 },
|
||||
});`}</Code>
|
||||
<Note>
|
||||
Сиденье водителя создаётся автоматически — отдельно регистрировать
|
||||
«вход» не нужно. Подошёл к машине → подсказка «[F] Enter» с её именем.
|
||||
Управление: <b>W</b> газ, <b>S</b> тормоз/назад, <b>A/D</b> руль,
|
||||
<b> Space</b> ручник, <b>V</b> камера, <b>E</b> выйти.
|
||||
</Note>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Реакция на посадку (квесты, деньги)</h3>
|
||||
<p>
|
||||
Глобальные события <code>onVehicleEnter</code> / <code>onVehicleExit</code>
|
||||
дают зацепку для логики игры — например, показать инструкцию или
|
||||
начать отсчёт заказа такси.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`game.onVehicleEnter((vehicleRef) => {
|
||||
game.ui.set('hint', 'Поехали! Отвези клиента.', { x: 50, y: 88, anchor: 'bottom' });
|
||||
});
|
||||
game.onVehicleExit((vehicleRef) => {
|
||||
game.ui.set('hint', 'Ты вышел из машины.', { x: 50, y: 88, anchor: 'bottom' });
|
||||
});`}</Code>
|
||||
|
||||
<Shot src="guide-taxisim-scene.png" wide
|
||||
caption="Гараж с машинами: подсказка «[F] Enter» появляется у автомобиля, когда игрок рядом. Вокруг — город из многоэтажек, фонарей и дорог." />
|
||||
|
||||
<h3 className="lessonH">Шаг 3. Hold-to-action (защита от случайных нажатий)</h3>
|
||||
<p>
|
||||
Любое взаимодействие можно сделать «по удержанию» — игрок должен
|
||||
подержать клавишу, а не случайно ткнуть. Полезно для важных действий
|
||||
(подобрать клиента, продать машину). Опция <code>holdDuration</code> в
|
||||
<code> onInteract</code>:
|
||||
</p>
|
||||
<ScriptKind kind="object" />
|
||||
<Code>{`game.self.onInteract(() => {
|
||||
game.ui.set('hint', 'Клиент сел!', { x: 50, y: 88, anchor: 'bottom' });
|
||||
}, { text: 'Подобрать клиента', distance: 5, key: 'f', holdDuration: 0.5 });`}</Code>
|
||||
|
||||
<h3 className="lessonH">Что движок делает сам</h3>
|
||||
<p>
|
||||
Тебе не нужно писать «низкоуровневую» возню — движок берёт её на себя:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>Машина сама встаёт на землю.</b> Где бы ты ни задал спавн,
|
||||
авто опускается на пол/дорогу — не висит в воздухе и не тонет;</li>
|
||||
<li><b>Звук мотора.</b> Пока едешь — слышен живой рокот двигателя:
|
||||
чем быстрее, тем плотнее и громче. На стоянке — тихо;</li>
|
||||
<li><b>Водитель скрывается за рулём</b> и появляется сбоку при выходе;</li>
|
||||
<li><b>Падение в бездну</b> = автоматический выход + респавн на старте.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="lessonH">Почему это важно</h3>
|
||||
<p>
|
||||
Машина — отдельная подсистема: пока ты за рулём, WASD управляют
|
||||
<i> автомобилем</i>, а не ходьбой, и камера автоматически переключается
|
||||
на «погоню за машиной». Выход возвращает обычное управление. На этой
|
||||
базе строятся целые игры: такси, гонки, доставка, мафия-симулятор.
|
||||
</p>
|
||||
|
||||
<Try>
|
||||
Заспавни вторую машину другой модели (<code>model: 'car-suv-luxury'</code>)
|
||||
с другим цветом и параметрами (быстрее: <code>maxSpeed: 20</code>).
|
||||
Поставь рядом столб (<code>game.scene.spawn('primitive:cylinder', ...)</code>)
|
||||
и проверь — машина останавливается, столб стоит.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/** Есть ли готовый текст урока для игры с таким id. */
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 тоже на всякий
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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');
|
||||
|
||||
95
src/editor/engine/VehicleHud.js
Normal file
95
src/editor/engine/VehicleHud.js
Normal file
@ -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(`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8d0dc" stroke-width="2"/>`);
|
||||
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(`<text x="${lx.toFixed(1)}" y="${ly.toFixed(1)}" fill="#9aa6b8" font-size="9" text-anchor="middle">${val}</text>`);
|
||||
}
|
||||
root.innerHTML =
|
||||
`<svg viewBox="0 0 160 160" width="160" height="160">` +
|
||||
`<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(16,20,32,0.82)" stroke="#3a4760" stroke-width="3"/>` +
|
||||
ticks.join('') +
|
||||
`<line id="kbn-veh-needle" x1="${CX}" y1="${CY}" x2="${CX}" y2="${CY - R + 18}" stroke="#ff5a3c" stroke-width="3.5" stroke-linecap="round" transform="rotate(-135 ${CX} ${CY})"/>` +
|
||||
`<circle cx="${CX}" cy="${CY}" r="6" fill="#ff5a3c"/>` +
|
||||
`<text id="kbn-veh-speed" x="${CX}" y="${CY + 30}" fill="#ffe44a" font-size="22" font-weight="800" text-anchor="middle">0</text>` +
|
||||
`<text x="${CX}" y="${CY + 44}" fill="#9aa6b8" font-size="9" text-anchor="middle">км/ч</text>` +
|
||||
`<text id="kbn-veh-gear" x="${CX}" y="${CY - 16}" fill="#7fe0a0" font-size="18" font-weight="900" text-anchor="middle">N</text>` +
|
||||
`</svg>`;
|
||||
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 = '<div><b>WASD</b> — руль</div><div><b>V</b> — камера</div><div><b>E</b> — выйти</div>';
|
||||
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(); }
|
||||
}
|
||||
251
src/editor/engine/VehicleManager.js
Normal file
251
src/editor/engine/VehicleManager.js
Normal file
@ -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<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 {
|
||||
// Поднимем чуть вверх и роняем с запасом (до ~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();
|
||||
}
|
||||
}
|
||||
@ -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?.();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user