Compare commits

...

2 Commits

Author SHA1 Message Date
min
24b6360266 feat(14): Vehicle System V1+V2 � ���� � ����� (#19)
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m36s
CI / Secret scan (push) Successful in 2m31s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 2m25s
2026-06-02 23:37:41 +00:00
min
eb6430182b feat(14): Vehicle System V1+V2 — порт в плеер
All checks were successful
CI / Lint (pull_request) Successful in 57s
CI / Build (pull_request) Successful in 1m34s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 7s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
Фича-парность со студией (задача 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>
2026-06-03 02:25:15 +03:00
9 changed files with 4247 additions and 3592 deletions

View File

@ -593,8 +593,10 @@ const KubikonPlayer = () => {
try { scene.setPlayerModelType?.(mySkin); } catch (e) {}
setLoading(false);
// Засчитываем плей
Kubikon3DApi.incrementPlay(projectId).catch(() => {});
// Засчитываем плей. Передаём user_id (если залогинен)
// это активирует self-cooldown (автор не накручивает себе)
// и user-cooldown на бэке (см. /play в Kubikon3D.py).
Kubikon3DApi.incrementPlay(projectId, userId).catch(() => {});
// Запускаем игру сразу
setTimeout(() => {
scene.enterPlayMode?.();

View File

@ -198,8 +198,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: повторный голос того же типа снимает,
* голос другого типа переключает. */

View File

@ -67,6 +67,8 @@ import { BeamManager } from './BeamManager';
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
import { LoadingScreenOverlay } from './LoadingScreenOverlay';
import { VehicleManager } from './VehicleManager';
import { VehicleHud } from './VehicleHud';
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment';
@ -150,6 +152,9 @@ export class BabylonScene {
// Placement mode (задача 11) — фича-парность со студией.
this.placementManager = null;
this.shopInventoryUi = null;
this.vehicleManager = null; // задача 14
this.vehicleHud = null;
this._VehicleHudClass = VehicleHud;
this._PlacementManagerClass = PlacementManager;
this._ShopInventoryUiClass = ShopInventoryUi;
// Экран загрузки (задача 12).
@ -1304,6 +1309,7 @@ export class BabylonScene {
// Voxel-террейн тоже участвует в физике. У террейна свой размер
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
this.vehicleManager = new VehicleManager(this); // задача 14
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
// voxelWorld), что позволяет постепенно мигрировать без поломки.
@ -1893,6 +1899,20 @@ export class BabylonScene {
if (typeof mesh.getBoundingInfo !== 'function') return;
if (typeof mesh.getTotalVertices !== 'function') return;
if (mesh.getTotalVertices() <= 0) return;
// ОПТИМИЗАЦИЯ ТЕНЕЙ (задача 14): мелкие/тонкие меши и огромный плоский
// пол НЕ кастят тень — каждый caster дорого стоит в shadow-map
// (на сцене из сотен примитивов давало 5-15 FPS вместо 45-60).
try {
const bb = mesh.getBoundingInfo().boundingBox;
const ext = bb.extendSizeWorld || bb.extendSize;
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);
if (maxDim < 1.6 || minDim < 0.35) return;
if (maxDim > 30 && Math.min(w, d) > 30 && h < 3) return;
}
} catch (e) { /* ignore */ }
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
}
@ -7561,6 +7581,8 @@ export class BabylonScene {
}
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
if (this.vehicleManager) { try { this.vehicleManager.dispose(); } catch (e) {} }
if (this.vehicleHud) { try { this.vehicleHud.dispose(); } catch (e) {} this.vehicleHud = null; }
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
if (this.loadingScreen) { try { this.loadingScreen.dispose(); } catch (e) {} this.loadingScreen = null; }

View File

@ -492,6 +492,12 @@ export class GameRuntime {
this._objectData = {};
this._interactables = [];
this._activeInteractRef = null;
// Задача 14: убрать машины и HUD водителя, чтобы при повторном start
// не плодились дубликаты (в плеере start может вызываться повторно).
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 = {};
@ -615,6 +621,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;
@ -784,7 +793,34 @@ export class GameRuntime {
}
/** Резолв позиции интерактивного объекта (по ref). */
_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;
}
_resolveInteractPos(it) {
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;
@ -802,9 +838,27 @@ 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._fireInteract(it);
}
_fireInteract(it) {
if (!it) return;
if (it.isInst) {
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'instInteract', ref: it.ref });
} else if (it.target) {
this.routeEvent(it.target, 'interact', {});
}
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 секунд. */
_updateTweens(dt) {
@ -2591,6 +2645,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) {
@ -2598,6 +2654,22 @@ export class GameRuntime {
}
return;
}
if (cmd === 'inst.registerInteract') {
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;
@ -3392,6 +3464,31 @@ export class GameRuntime {
}
this.scheduleSceneSnapshot();
}
} else if (kind === 'vehicle') {
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);
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));

View File

@ -314,9 +314,10 @@ export class ModelManager {
r.getChildMeshes(false).forEach(m => {
m.isPickable = true;
m.metadata = { isModel: true, instanceId: this._nextInstanceId };
// Тени: GLB-модель и принимает тени, и отбрасывает их
// (через addShadowCaster в refreshAllShadows).
// Тени: на InstancedMesh receiveShadows не действует (warning+нагрузка).
if (m.getClassName && m.getClassName() !== 'InstancedMesh') {
m.receiveShadows = true;
}
clonedMeshes.push(m);
});
// И сам root тоже на всякий

View File

@ -1823,6 +1823,143 @@ export class PlayerController {
this._cameraOverride = null;
}
// ===== Задача 14: вождение машины =====
enterVehicle(veh) {
if (!veh) return;
this._inVehicle = veh;
this._vehicleCamMode = 'follow';
veh.driver = 'player';
if (this._codes) this._codes.clear();
this._skinVisibleScripted = false;
this._startEngineSound();
}
// Звук мотора: низкочастотный РОКОТ (бас-пила + отфильтрованный шум +
// 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;
const osc = ctx.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 45;
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;
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 350; lp.Q.value = 0.5;
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.connect(lfoGain); lfoGain.connect(gain.gain);
osc.start(); noise.start(); lfo.start();
this._engineNodes = { osc, noise, noiseLp, noiseGain, lp, lfo, lfoGain, gain };
} catch (e) {}
}
_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;
n.osc.frequency.setTargetAtTime(45 + frac * 50, t, 0.12);
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);
n.gain.gain.setTargetAtTime(0.05 + frac * 0.08, t, 0.12);
} catch (e) {}
}
_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) {}
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) {} }
}
}
}
cycleVehicleCamera() {
const modes = ['follow', 'hood', 'cinematic'];
const i = modes.indexOf(this._vehicleCamMode || 'follow');
this._vehicleCamMode = modes[(i + 1) % modes.length];
}
_tickVehicle(dt) {
const veh = this._inVehicle;
if (!veh || !this._scene3d?.vehicleManager) 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 _vres = this._scene3d.vehicleManager.tickVehicle(veh, dt);
this._updateEngineSound(veh.speed, veh.params?.maxSpeed);
if (_vres && _vres.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 {
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);
}
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;
@ -2416,6 +2553,15 @@ export class PlayerController {
return;
}
this._codes.add(e.code);
// Задача 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) {} }
}
}
if (e.shiftKey) this._shift = true;
// C — переключение first/third. Отключаем в GD-режиме (автобег > 0)
// и при любом активном GD-гейммоде, потому что ломает физику auto-run + sideview.
@ -2487,6 +2633,12 @@ export class PlayerController {
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).

View File

@ -79,6 +79,8 @@ let _playerJoinHandlers = [];
let _playerLeaveHandlers = [];
// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7).
let _cutsceneDoneHandlers = [];
let _vehicleEnterHandlers = []; // задача 14
let _vehicleExitHandlers = [];
let _mpMessageHandlers = {}; // name → [fn]
// Подписки game.room.onChange(key, fn): key → [fn].
let _roomChangeHandlers = {};
@ -102,7 +104,7 @@ let _selfInteractHandlers = [];
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')
@ -426,6 +428,17 @@ function _getOrCreateInstance(ref, kindHint) {
_instHandlerBucket(ref).click.push(fn);
_send('inst.watchClick', { ref });
};
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;
},
@ -647,6 +660,7 @@ 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',
holdDuration: (opts && Number.isFinite(opts.holdDuration)) ? opts.holdDuration : 0,
});
},
move(x, y, z) {
@ -1285,6 +1299,8 @@ const game = {
onCutsceneDone(fn) {
if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn);
},
onVehicleEnter(fn) { if (typeof fn === 'function') _vehicleEnterHandlers.push(fn); },
onVehicleExit(fn) { if (typeof fn === 'function') _vehicleExitHandlers.push(fn); },
/** Игрок покинул комнату. fn({sessionId, name}). */
onPlayerLeave(fn) {
if (typeof fn === 'function') _playerLeaveHandlers.push(fn);
@ -1469,6 +1485,17 @@ const game = {
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
return ref;
}
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. */
@ -3794,6 +3821,9 @@ 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') {
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') {
@ -3825,6 +3855,12 @@ self.onmessage = (e) => {
} else if (t === 'cutsceneDone') {
// Катсцена камеры завершилась (Фаза 5.7).
for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone');
} else if (t === 'vehicleEnter') {
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/engine/VehicleHud.js Normal file
View 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(); }
}

View File

@ -0,0 +1,249 @@
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();
}
}