Compare commits
12 Commits
7a183a192a
...
147a7588d9
| Author | SHA1 | Date | |
|---|---|---|---|
| 147a7588d9 | |||
| 24b6360266 | |||
|
|
eb6430182b | ||
| 4ca8cdd9bd | |||
|
|
b2cff903ba | ||
| dd7688c4d7 | |||
|
|
302db5e1f4 | ||
|
|
f420501481 | ||
|
|
9e3bc60a76 | ||
| 61ac40ab61 | |||
|
|
91af8514c5 | ||
|
|
517545b0cf |
@ -150,9 +150,9 @@ jobs:
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -o ConnectTimeout=20 -p 1998 \
|
||||
min@85.175.7.40 \
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/ 2>/dev/null || true"
|
||||
- name: Verify S2 (обязательный)
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy -o UserKnownHostsFile=~/.ssh/known_hosts -p 22 \
|
||||
min@192.168.0.124 \
|
||||
"ls /var/www/rublox-player/build/index.html && du -sh /var/www/rublox-player/build/"
|
||||
"ls /var/www/rublox-player/build/index.html && (du -sh /var/www/rublox-player/build/ 2>/dev/null || true)"
|
||||
|
||||
@ -596,8 +596,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?.();
|
||||
|
||||
@ -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: повторный голос того же типа снимает,
|
||||
* голос другого типа — переключает. */
|
||||
|
||||
@ -16,6 +16,13 @@ import Icon from './Icon';
|
||||
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
|
||||
if (!visible) return null;
|
||||
|
||||
// ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни
|
||||
// одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar
|
||||
// из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен.
|
||||
// Панель появится автоматически, как только в слот попадёт предмет.
|
||||
const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null);
|
||||
if (!hasAnyItem) return null;
|
||||
|
||||
const SLOT_COUNT = 5;
|
||||
const cells = [];
|
||||
for (let i = 0; i < SLOT_COUNT; i++) {
|
||||
|
||||
@ -64,6 +64,11 @@ import { ZombieManager } from './ZombieManager';
|
||||
import { NpcManager } from './NpcManager';
|
||||
import { ConstraintManager } from './ConstraintManager';
|
||||
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';
|
||||
@ -144,6 +149,20 @@ export class BabylonScene {
|
||||
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
|
||||
this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
|
||||
this.beamManager = null; // лучи и следы (Фаза 5.2)
|
||||
// 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).
|
||||
this.loadingScreen = null;
|
||||
this._LoadingScreenOverlayClass = LoadingScreenOverlay;
|
||||
this._loadingConfig = null;
|
||||
this._mainMenuConfig = null; // задача 13
|
||||
this._projectThumbnail = null;
|
||||
this.spawnerManager = null; // спавнеры зомби
|
||||
this.environment = null;
|
||||
this.audioManager = null;
|
||||
@ -1290,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), что позволяет постепенно мигрировать без поломки.
|
||||
@ -1488,6 +1508,10 @@ export class BabylonScene {
|
||||
if (this._isPlaying && this.modalManager?.tick) {
|
||||
try { this.modalManager.tick(dt); } catch (e) {}
|
||||
}
|
||||
// Задача 12: loadingScreen.tick — fade/auto-duration независимо от paused.
|
||||
if (this._isPlaying && this.loadingScreen?.tick) {
|
||||
try { this.loadingScreen.tick(dt); } catch (e) {}
|
||||
}
|
||||
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
|
||||
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
|
||||
this.gameRuntime.tick(dt);
|
||||
@ -1875,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 */ }
|
||||
}
|
||||
|
||||
@ -2185,6 +2223,10 @@ export class BabylonScene {
|
||||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||||
// При pointer-lock курсор в центре; в third (свободный курсор)
|
||||
// передаём реальные координаты клика для pick по табличкам.
|
||||
if (this.placementManager && this.placementManager.isActive()) {
|
||||
if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; }
|
||||
if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; }
|
||||
}
|
||||
if (e.button === 0) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
|
||||
@ -2383,6 +2425,10 @@ export class BabylonScene {
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
||||
this.placementManager.rotate();
|
||||
return;
|
||||
}
|
||||
const forward = this._getCameraForward();
|
||||
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
|
||||
this.camera.position.addInPlace(forward.scale(delta));
|
||||
@ -2419,6 +2465,10 @@ export class BabylonScene {
|
||||
const key = this._normalizeKey(e);
|
||||
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
|
||||
}
|
||||
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
|
||||
if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; }
|
||||
if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; }
|
||||
}
|
||||
if (e.code === 'KeyF') {
|
||||
this._focusOnTarget(new Vector3(0, 0, 0));
|
||||
}
|
||||
@ -7308,6 +7358,21 @@ export class BabylonScene {
|
||||
} else {
|
||||
this._skinsConfig = null;
|
||||
}
|
||||
// Задача 12: конфиг экрана загрузки.
|
||||
if (state.scene.loadingScreen && typeof state.scene.loadingScreen === 'object') {
|
||||
const ls = state.scene.loadingScreen;
|
||||
this._loadingConfig = {
|
||||
logo: ls.logo || null,
|
||||
accentColor: ls.accentColor || '#ffc020',
|
||||
defaultSpinner: ls.defaultSpinner !== false,
|
||||
defaultSkipButton: !!ls.defaultSkipButton,
|
||||
};
|
||||
} else {
|
||||
this._loadingConfig = null;
|
||||
}
|
||||
// Задача 13: конфиг главного меню (passthrough).
|
||||
this._mainMenuConfig = (state.scene.mainMenu && typeof state.scene.mainMenu === 'object')
|
||||
? state.scene.mainMenu : null;
|
||||
|
||||
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
|
||||
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
|
||||
@ -7515,6 +7580,13 @@ export class BabylonScene {
|
||||
this.gameRuntime = null;
|
||||
}
|
||||
|
||||
// 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; }
|
||||
|
||||
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
||||
if (this.gdLevelManager) {
|
||||
this.gdLevelManager.stop();
|
||||
|
||||
@ -311,6 +311,47 @@ export class GameRuntime {
|
||||
return this._skinState;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация PlacementManager (задача 11). */
|
||||
_ensurePlacementManager() {
|
||||
if (this.scene3d?.placementManager) return this.scene3d.placementManager;
|
||||
if (!this.scene3d || !this.scene3d.scene) return null;
|
||||
try {
|
||||
if (this.scene3d._PlacementManagerClass) {
|
||||
this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d);
|
||||
}
|
||||
} catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); }
|
||||
return this.scene3d.placementManager || null;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */
|
||||
_ensureShopInventory() {
|
||||
if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi;
|
||||
if (!this.scene3d) return null;
|
||||
try {
|
||||
if (this.scene3d._ShopInventoryUiClass) {
|
||||
this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d);
|
||||
}
|
||||
} catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); }
|
||||
return this.scene3d.shopInventoryUi || null;
|
||||
}
|
||||
|
||||
/** Ленивая инициализация экрана загрузки (задача 12). */
|
||||
_ensureLoadingScreen() {
|
||||
if (this.scene3d?.loadingScreen) return this.scene3d.loadingScreen;
|
||||
if (!this.scene3d) return null;
|
||||
try {
|
||||
if (this.scene3d._LoadingScreenOverlayClass) {
|
||||
const ls = new this.scene3d._LoadingScreenOverlayClass(this.scene3d);
|
||||
ls.setBridge(
|
||||
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingSkip', loadingId: id }); },
|
||||
(id) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingComplete', loadingId: id }); },
|
||||
);
|
||||
this.scene3d.loadingScreen = ls;
|
||||
}
|
||||
} catch (e) { this._log('error', 'loadingScreen init: ' + (e?.message || e)); }
|
||||
return this.scene3d.loadingScreen || null;
|
||||
}
|
||||
|
||||
/** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
|
||||
_resolveSkinTypeId(slug) {
|
||||
if (!slug) return 'character-a';
|
||||
@ -451,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 = {};
|
||||
@ -502,6 +549,10 @@ export class GameRuntime {
|
||||
s?.modelManager?.removeInstance(Number(rest));
|
||||
} else if (kind === 'primitive') {
|
||||
s?.primitiveManager?.removeInstance(Number(rest));
|
||||
} else if (kind === 'usermodel') {
|
||||
// Воксельные модели, наспавненные скриптом (placement) —
|
||||
// удаляем при Stop, иначе placed-объекты остаются. См. studio.
|
||||
s?.userModelManager?.removeInstance(Number(rest));
|
||||
}
|
||||
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
|
||||
}
|
||||
@ -570,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;
|
||||
@ -739,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;
|
||||
@ -757,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) {
|
||||
@ -1585,6 +1684,56 @@ export class GameRuntime {
|
||||
return;
|
||||
}
|
||||
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
||||
// === Placement mode (задача 11) ===
|
||||
if (cmd === 'placement.start') {
|
||||
const pm = this._ensurePlacementManager();
|
||||
if (pm && payload) {
|
||||
pm.setCallbacks({
|
||||
onPlace: (res) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); },
|
||||
onCancel: () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); },
|
||||
onMove: (mv) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); },
|
||||
});
|
||||
try { pm.start(payload.itemKey, payload.opts || {}); }
|
||||
catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; }
|
||||
if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; }
|
||||
if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; }
|
||||
if (cmd === 'inventoryUi.create') {
|
||||
const im = this._ensureShopInventory();
|
||||
if (im && payload) {
|
||||
try { im.create(payload, (item) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); }); }
|
||||
catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inventoryUi.setBalance') {
|
||||
this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount);
|
||||
this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount);
|
||||
return;
|
||||
}
|
||||
if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; }
|
||||
|
||||
// === Экран загрузки (задача 12) ===
|
||||
if (cmd === 'loading.show') {
|
||||
const ls = this._ensureLoadingScreen();
|
||||
if (ls && payload) {
|
||||
try {
|
||||
const id = ls.show(payload.opts || {});
|
||||
if (payload.replyId != null) {
|
||||
for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'loadingShown', replyId: payload.replyId, loadingId: id });
|
||||
}
|
||||
} catch (e) { this._log('error', 'loading.show: ' + (e?.message || e)); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cmd === 'loading.setProgress') { this.scene3d?.loadingScreen?.setProgress(payload?.value); return; }
|
||||
if (cmd === 'loading.setText') { this.scene3d?.loadingScreen?.setText(payload?.text); return; }
|
||||
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
|
||||
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
|
||||
|
||||
if (cmd === 'fx.create') {
|
||||
// payload: { kind: 'beam'|'trail', localRef, ... }
|
||||
const bm = this.scene3d?.beamManager;
|
||||
@ -2218,6 +2367,11 @@ export class GameRuntime {
|
||||
this.scene3d?.player?.cameraReset?.();
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setInputBlocked') {
|
||||
// Задача 13: блок управления (главное меню — игрок наблюдатель).
|
||||
this.scene3d?.player?.setInputBlocked?.(!!payload?.blocked);
|
||||
return;
|
||||
}
|
||||
if (cmd === 'player.setSkinVisible') {
|
||||
const player = this.scene3d?.player;
|
||||
if (player) {
|
||||
@ -2491,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) {
|
||||
@ -2498,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;
|
||||
@ -3242,6 +3414,7 @@ export class GameRuntime {
|
||||
const opts = payload;
|
||||
const p = this.scene3d?.userModelManager?.addInstance(
|
||||
subType, opts.x, opts.y, opts.z, opts.rotationY || 0,
|
||||
(opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {},
|
||||
);
|
||||
Promise.resolve(p).then((instId) => {
|
||||
if (instId == null) return;
|
||||
@ -3291,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));
|
||||
|
||||
399
src/engine/LoadingScreenOverlay.js
Normal file
399
src/engine/LoadingScreenOverlay.js
Normal file
@ -0,0 +1,399 @@
|
||||
/**
|
||||
* LoadingScreenOverlay — внутриигровой экран загрузки (задача 12).
|
||||
*
|
||||
* Программный mid-game transition: чёрный фон (fadeIn/Out), картинка-превью
|
||||
* (cover) по центру, прогресс-бар (жёлтый по серому) + процент, спиннер
|
||||
* «ЗАГРУЗКА» справа-снизу (CSS keyframes), кнопка «ПРОПУСТИТЬ» по центру-снизу
|
||||
* (появляется через 0.5с — анти-accidental), логотип игры слева-снизу.
|
||||
*
|
||||
* Вызывается из скрипта через game.loading.show(opts) / game.loading.transition(opts).
|
||||
* Покрывает и кейс задачи 05 (начальный экран при входе).
|
||||
*
|
||||
* Реализация — лёгкий DOM-оверлей поверх canvas (как ShopInventoryUi), а не
|
||||
* Babylon-GUI: фиксированный layout с прогресс-баром/спиннером/кнопкой на HTML
|
||||
* делается быстрее и доступнее. Класс самодостаточен: хранит state, рисует DOM,
|
||||
* имеет tick(dt) для fade-фаз и авто-duration (в отличие от ShopInventoryUi,
|
||||
* которому tick не нужен).
|
||||
*
|
||||
* Один активный экран одновременно: повторный show() мгновенно закрывает
|
||||
* предыдущий (как ModalManager) — нет утечки overlay'ев при нескольких
|
||||
* transition подряд.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
const EASE_OUT = (t) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
// CSS спиннера вставляем один раз в <head> (keyframes нельзя инлайнить в style).
|
||||
let _spinCssInjected = false;
|
||||
function injectSpinnerCss() {
|
||||
if (_spinCssInjected) return;
|
||||
_spinCssInjected = true;
|
||||
try {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'kbn-loading-spin-css';
|
||||
style.textContent =
|
||||
'@keyframes kbn-ls-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}' +
|
||||
'.kbn-ls-spinner{animation:kbn-ls-spin 0.9s linear infinite}' +
|
||||
'@media (prefers-reduced-motion:reduce){.kbn-ls-spinner{animation:none}}';
|
||||
document.head.appendChild(style);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export class LoadingScreenOverlay {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.root = null;
|
||||
this._st = null; // state активного экрана или null
|
||||
this._idSeq = 0;
|
||||
// Мост наружу (GameRuntime подписывает) — id-based колбэки.
|
||||
this._onSkipCb = null; // (id) => void
|
||||
this._onCompleteCb = null; // (id) => void
|
||||
// DOM-ссылки активного экрана:
|
||||
this._els = null;
|
||||
}
|
||||
|
||||
/** Колбэки-мост к GameRuntime (он рассылает globalEvent в sandbox'ы). */
|
||||
setBridge(onSkip, onComplete) {
|
||||
this._onSkipCb = onSkip;
|
||||
this._onCompleteCb = onComplete;
|
||||
}
|
||||
|
||||
/** Конфиг проекта (логотип/акцент по умолчанию) из BabylonScene._loadingConfig. */
|
||||
_cfg() {
|
||||
return (this.s && this.s._loadingConfig) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать экран загрузки. Возвращает числовой id (для матчинга команд).
|
||||
* opts — см. 12_ingame_loading.md §2.2.
|
||||
*/
|
||||
show(opts) {
|
||||
injectSpinnerCss();
|
||||
opts = opts && typeof opts === 'object' ? opts : {};
|
||||
// Один активный — мгновенно убрать предыдущий.
|
||||
if (this._st) this._instantClose();
|
||||
|
||||
const cfg = this._cfg();
|
||||
const accent = opts.progressColor || cfg.accentColor || '#ffc020';
|
||||
const st = {
|
||||
id: ++this._idSeq,
|
||||
// Фон
|
||||
bgColor: opts.bgColor || '#000',
|
||||
bgOpacity: opts.bgOpacity != null ? Number(opts.bgOpacity) : 1,
|
||||
fadeIn: opts.fadeIn != null ? Number(opts.fadeIn) : 0.3,
|
||||
fadeOut: opts.fadeOut != null ? Number(opts.fadeOut) : 0.3,
|
||||
// Прогресс
|
||||
progressBar: opts.progressBar !== false,
|
||||
progressColor: accent,
|
||||
progressBgColor: opts.progressBgColor || '#444',
|
||||
percentText: opts.percentText !== false,
|
||||
progress: Math.max(0, Math.min(1, Number(opts.initialProgress) || 0)),
|
||||
duration: Number.isFinite(opts.duration) && opts.duration > 0 ? Number(opts.duration) : null,
|
||||
manualProgress: false,
|
||||
// Спиннер
|
||||
spinner: opts.spinner != null ? !!opts.spinner : (cfg.defaultSpinner !== false),
|
||||
spinnerText: opts.spinnerText != null ? String(opts.spinnerText) : 'ЗАГРУЗКА',
|
||||
// Кнопка Пропустить
|
||||
skipButton: opts.skipButton != null ? !!opts.skipButton : !!cfg.defaultSkipButton,
|
||||
skipButtonText: opts.skipButtonText != null ? String(opts.skipButtonText) : 'ПРОПУСТИТЬ',
|
||||
skipButtonColor: opts.skipButtonColor || accent,
|
||||
skipShown: false,
|
||||
// Логотип
|
||||
logo: opts.logo || cfg.logo || (this.s && this.s._projectThumbnail) || null,
|
||||
logoCornerRadius: opts.logoCornerRadius != null ? Number(opts.logoCornerRadius) : 12,
|
||||
// Текст под картинкой
|
||||
text: opts.text != null ? String(opts.text) : '',
|
||||
// Поведение
|
||||
blockInput: opts.blockInput !== false,
|
||||
pauseSimulation: opts.pauseSimulation !== false,
|
||||
// Жизненный цикл
|
||||
phase: 'in', // 'in' | 'visible' | 'out'
|
||||
alpha: 0,
|
||||
elapsed: 0, // время с момента полного появления (для duration/skip)
|
||||
fadeT: 0,
|
||||
completed: false, // onComplete уже вызывался
|
||||
};
|
||||
this._st = st;
|
||||
this._build(st, opts.cover);
|
||||
|
||||
// Блок ввода + пауза симуляции.
|
||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(true); } catch { /* ignore */ } }
|
||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = true; } catch { /* ignore */ } }
|
||||
|
||||
return st.id;
|
||||
}
|
||||
|
||||
/** Резолв cover в URL/dataURL. */
|
||||
_resolveCover(cover) {
|
||||
if (!cover) return null;
|
||||
if (typeof cover === 'string') {
|
||||
// asset:xxx → пробуем через AssetManager, иначе как прямой URL.
|
||||
try {
|
||||
const r = this.s.assetManager?.resolveUrl?.(cover);
|
||||
if (r) return r;
|
||||
} catch { /* ignore */ }
|
||||
return cover;
|
||||
}
|
||||
if (typeof cover === 'object') {
|
||||
if (cover.sceneSnapshot) {
|
||||
try {
|
||||
const canvas = this.s.engine?.getRenderingCanvas?.();
|
||||
if (canvas) return canvas.toDataURL('image/jpeg', 0.72);
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
if (cover.url) return cover.url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_build(st, cover) {
|
||||
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-loading';
|
||||
root.style.cssText =
|
||||
'position:absolute;inset:0;z-index:60;overflow:hidden;' +
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
'opacity:0;transition:none;font-family:system-ui,"Segoe UI",sans-serif;' +
|
||||
`background:${st.bgColor};`;
|
||||
// фон с настраиваемой непрозрачностью — отдельный слой, чтобы контент был непрозрачным
|
||||
// (используем opacity всего root для fade, а bgOpacity — через rgba фон):
|
||||
root.style.background = this._bgRgba(st.bgColor, st.bgOpacity);
|
||||
|
||||
// --- Cover (картинка по центру) ---
|
||||
const coverUrl = this._resolveCover(cover);
|
||||
const coverImg = document.createElement('div');
|
||||
coverImg.style.cssText =
|
||||
'max-width:min(62vw,860px);width:62vw;aspect-ratio:16/9;border-radius:16px;' +
|
||||
'box-shadow:0 18px 60px rgba(0,0,0,0.6);background-size:cover;background-position:center;' +
|
||||
'background-color:#1a1f2b;margin-bottom:140px;';
|
||||
if (coverUrl) coverImg.style.backgroundImage = `url("${coverUrl}")`;
|
||||
|
||||
// --- Текст под картинкой ---
|
||||
const textEl = document.createElement('div');
|
||||
textEl.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:170px;' +
|
||||
'color:#e8edf5;font-size:18px;font-weight:500;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,0.6);';
|
||||
textEl.textContent = st.text || '';
|
||||
|
||||
// --- Прогресс-бар ---
|
||||
const barWrap = document.createElement('div');
|
||||
barWrap.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:120px;' +
|
||||
`width:min(74vw,1180px);height:14px;border-radius:8px;background:${st.progressBgColor};` +
|
||||
'overflow:hidden;box-shadow:inset 0 1px 3px rgba(0,0,0,0.5);' +
|
||||
(st.progressBar ? '' : 'display:none;');
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText =
|
||||
`height:100%;width:${(st.progress * 100).toFixed(1)}%;border-radius:8px;` +
|
||||
`background:linear-gradient(90deg,${st.progressColor},${this._lighten(st.progressColor)});` +
|
||||
'transition:width 0.12s linear;box-shadow:0 0 8px rgba(255,200,40,0.4);';
|
||||
barWrap.appendChild(bar);
|
||||
|
||||
// --- Процент ---
|
||||
const percent = document.createElement('div');
|
||||
percent.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:74px;' +
|
||||
`color:${st.progressColor};font-size:30px;font-weight:800;text-shadow:0 2px 4px rgba(0,0,0,0.5);` +
|
||||
(st.percentText ? '' : 'display:none;');
|
||||
percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||||
|
||||
// --- Кнопка Пропустить ---
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.type = 'button';
|
||||
skipBtn.textContent = st.skipButtonText;
|
||||
skipBtn.style.cssText =
|
||||
'position:absolute;left:50%;transform:translateX(-50%);bottom:18px;' +
|
||||
'min-width:260px;padding:14px 36px;border:none;border-radius:12px;cursor:pointer;' +
|
||||
`background:linear-gradient(180deg,${this._lighten(st.skipButtonColor)},${st.skipButtonColor});` +
|
||||
'color:#3a2a00;font-size:18px;font-weight:800;letter-spacing:0.5px;' +
|
||||
'box-shadow:0 6px 16px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.4);' +
|
||||
'opacity:0;transition:opacity 0.25s,transform 0.1s;pointer-events:none;' +
|
||||
(st.skipButton ? '' : 'display:none;');
|
||||
skipBtn.onmouseenter = () => { skipBtn.style.transform = 'translateX(-50%) translateY(-2px)'; };
|
||||
skipBtn.onmouseleave = () => { skipBtn.style.transform = 'translateX(-50%)'; };
|
||||
skipBtn.onclick = () => {
|
||||
if (skipBtn.style.pointerEvents === 'none') return;
|
||||
this._fireSkip();
|
||||
};
|
||||
|
||||
// --- Логотип (слева снизу) ---
|
||||
const logo = document.createElement('div');
|
||||
logo.style.cssText =
|
||||
'position:absolute;left:28px;bottom:24px;max-width:200px;max-height:110px;' +
|
||||
`border-radius:${st.logoCornerRadius}px;background-size:contain;background-repeat:no-repeat;` +
|
||||
'background-position:left bottom;width:200px;height:90px;';
|
||||
if (st.logo) logo.style.backgroundImage = `url("${st.logo}")`;
|
||||
else logo.style.display = 'none';
|
||||
|
||||
// --- Спиннер + «ЗАГРУЗКА» (справа снизу) ---
|
||||
const spinWrap = document.createElement('div');
|
||||
spinWrap.style.cssText =
|
||||
'position:absolute;right:32px;bottom:32px;display:flex;align-items:center;gap:14px;' +
|
||||
'color:#fff;font-size:20px;font-weight:700;letter-spacing:1px;' +
|
||||
(st.spinner ? '' : 'display:none;');
|
||||
const spinTxt = document.createElement('span');
|
||||
spinTxt.textContent = st.spinnerText;
|
||||
const spinCircle = document.createElement('span');
|
||||
spinCircle.className = 'kbn-ls-spinner';
|
||||
spinCircle.style.cssText =
|
||||
`display:inline-block;width:28px;height:28px;border:3px solid rgba(255,255,255,0.25);` +
|
||||
`border-top-color:${st.progressColor};border-radius:50%;`;
|
||||
spinWrap.appendChild(spinTxt);
|
||||
spinWrap.appendChild(spinCircle);
|
||||
|
||||
root.appendChild(coverImg);
|
||||
root.appendChild(textEl);
|
||||
root.appendChild(barWrap);
|
||||
root.appendChild(percent);
|
||||
root.appendChild(skipBtn);
|
||||
root.appendChild(logo);
|
||||
root.appendChild(spinWrap);
|
||||
parent.appendChild(root);
|
||||
|
||||
this.root = root;
|
||||
this._els = { root, coverImg, textEl, barWrap, bar, percent, skipBtn, logo, spinWrap };
|
||||
}
|
||||
|
||||
/** tick — fade-фазы, авто-duration, появление кнопки Пропустить. */
|
||||
tick(dt) {
|
||||
const st = this._st;
|
||||
if (!st || !this._els) return;
|
||||
dt = Number(dt) || 0;
|
||||
|
||||
if (st.phase === 'in') {
|
||||
st.fadeT += dt;
|
||||
const d = st.fadeIn > 0 ? st.fadeIn : 0.0001;
|
||||
st.alpha = Math.min(1, EASE_OUT(st.fadeT / d));
|
||||
this._els.root.style.opacity = String(st.alpha);
|
||||
if (st.fadeT >= d) { st.phase = 'visible'; st.alpha = 1; st.fadeT = 0; }
|
||||
} else if (st.phase === 'visible') {
|
||||
st.elapsed += dt;
|
||||
// Кнопка Пропустить — появляется через 0.5с.
|
||||
if (!st.skipShown && st.skipButton && st.elapsed >= 0.5) {
|
||||
st.skipShown = true;
|
||||
this._els.skipBtn.style.opacity = '1';
|
||||
this._els.skipBtn.style.pointerEvents = 'auto';
|
||||
}
|
||||
// Авто-duration (если не было ручного setProgress).
|
||||
if (st.duration && !st.manualProgress) {
|
||||
st.progress = Math.min(1, st.elapsed / st.duration);
|
||||
this._applyProgress(st);
|
||||
if (st.progress >= 1 && !st.completed) {
|
||||
st.completed = true;
|
||||
this._fireComplete();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
} else if (st.phase === 'out') {
|
||||
st.fadeT += dt;
|
||||
const d = st.fadeOut > 0 ? st.fadeOut : 0.0001;
|
||||
st.alpha = Math.max(0, 1 - EASE_OUT(st.fadeT / d));
|
||||
this._els.root.style.opacity = String(st.alpha);
|
||||
if (st.fadeT >= d) this._teardown();
|
||||
}
|
||||
}
|
||||
|
||||
_applyProgress(st) {
|
||||
if (!this._els) return;
|
||||
this._els.bar.style.width = `${(st.progress * 100).toFixed(1)}%`;
|
||||
this._els.percent.textContent = `${Math.round(st.progress * 100)}%`;
|
||||
}
|
||||
|
||||
setProgress(value) {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
st.manualProgress = true;
|
||||
st.progress = Math.max(0, Math.min(1, Number(value) || 0));
|
||||
this._applyProgress(st);
|
||||
if (st.progress >= 1 && !st.completed) {
|
||||
st.completed = true;
|
||||
this._fireComplete();
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
setText(text) {
|
||||
const st = this._st;
|
||||
if (!st || !this._els) return;
|
||||
st.text = String(text == null ? '' : text);
|
||||
this._els.textEl.textContent = st.text;
|
||||
}
|
||||
|
||||
setCover(cover) {
|
||||
if (!this._st || !this._els) return;
|
||||
const url = this._resolveCover(cover);
|
||||
if (url) this._els.coverImg.style.backgroundImage = `url("${url}")`;
|
||||
}
|
||||
|
||||
/** Закрыть программно (с fadeOut). */
|
||||
close() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (st.phase !== 'out') { st.phase = 'out'; st.fadeT = 0; }
|
||||
}
|
||||
|
||||
_fireSkip() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (this._onSkipCb) { try { this._onSkipCb(st.id); } catch { /* ignore */ } }
|
||||
this.close();
|
||||
}
|
||||
|
||||
_fireComplete() {
|
||||
const st = this._st;
|
||||
if (!st) return;
|
||||
if (this._onCompleteCb) { try { this._onCompleteCb(st.id); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
/** Мгновенно убрать без fade (повторный show / выход из Play). */
|
||||
_instantClose() {
|
||||
this._teardown();
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
// Снять блок ввода / паузу.
|
||||
const st = this._st;
|
||||
if (st) {
|
||||
if (st.blockInput) { try { this.s.player?.setInputBlocked?.(false); } catch { /* ignore */ } }
|
||||
if (st.pauseSimulation && this.s.gameRuntime) { try { this.s.gameRuntime.paused = false; } catch { /* ignore */ } }
|
||||
}
|
||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } }
|
||||
this.root = null;
|
||||
this._els = null;
|
||||
this._st = null;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._instantClose();
|
||||
this._onSkipCb = null;
|
||||
this._onCompleteCb = null;
|
||||
}
|
||||
|
||||
// --- утилиты цвета ---
|
||||
_lighten(hex) {
|
||||
try {
|
||||
const h = String(hex).replace('#', '');
|
||||
if (h.length !== 6) return hex;
|
||||
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + 40);
|
||||
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + 40);
|
||||
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + 40);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
} catch { return hex; }
|
||||
}
|
||||
|
||||
_bgRgba(hex, opacity) {
|
||||
try {
|
||||
const h = String(hex).replace('#', '');
|
||||
if (h.length !== 6) return hex;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = opacity != null ? Math.max(0, Math.min(1, opacity)) : 1;
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
} catch { return hex; }
|
||||
}
|
||||
}
|
||||
@ -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 тоже на всякий
|
||||
|
||||
586
src/engine/PlacementManager.js
Normal file
586
src/engine/PlacementManager.js
Normal file
@ -0,0 +1,586 @@
|
||||
/**
|
||||
* PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11).
|
||||
*
|
||||
* Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре →
|
||||
* полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет».
|
||||
* Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon).
|
||||
*
|
||||
* Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`.
|
||||
* Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx.
|
||||
*
|
||||
* Скриптовый API игры (через GameRuntime → game.placement.*):
|
||||
* start(itemKey, opts) — войти в режим расстановки
|
||||
* cancel() — выйти (как ПКМ/Esc)
|
||||
* confirm() — поставить на текущей позиции (как ЛКМ)
|
||||
* rotate(deg) — повернуть preview (как R / колесо)
|
||||
* onPlace / onCancel / onMove — колбэки (роутятся в worker как события)
|
||||
*
|
||||
* Фича-парность: идентичный модуль есть в rublox-player/src/engine/.
|
||||
*/
|
||||
import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core';
|
||||
|
||||
const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить
|
||||
const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя
|
||||
|
||||
export class PlacementManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d; // BabylonScene
|
||||
this.scene = scene3d.scene;
|
||||
this._active = null; // активная сессия placement или null
|
||||
this._tickObs = null; // observer renderLoop
|
||||
this._placementSeq = 0;
|
||||
// Колбэки (вызываются движком, GameRuntime роутит их в worker как события)
|
||||
this._onPlace = null;
|
||||
this._onCancel = null;
|
||||
this._onMove = null;
|
||||
}
|
||||
|
||||
setCallbacks({ onPlace, onCancel, onMove } = {}) {
|
||||
if (onPlace !== undefined) this._onPlace = onPlace;
|
||||
if (onCancel !== undefined) this._onCancel = onCancel;
|
||||
if (onMove !== undefined) this._onMove = onMove;
|
||||
}
|
||||
|
||||
isActive() { return !!this._active; }
|
||||
|
||||
/**
|
||||
* Войти в placement-режим.
|
||||
* @param {string} itemKey — ключ предмета (передаётся обратно в onPlace)
|
||||
* @param {object} opts — см. 11_placement_mode.md §2.1
|
||||
* @returns {string} placementId
|
||||
*/
|
||||
start(itemKey, opts = {}) {
|
||||
// Уже активна сессия — отменим прежнюю (без onCancel-шума автора).
|
||||
if (this._active) this._teardown(false);
|
||||
|
||||
const o = {
|
||||
previewType: opts.previewType || 'primitive:cube',
|
||||
previewColor: opts.previewColor || '#a0522d',
|
||||
previewScale: Number(opts.previewScale) || 1,
|
||||
// modelScale — реальный scale воксельной модели для превью (чтобы
|
||||
// полупрозрачная копия была того же размера, что и ставимый объект).
|
||||
modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1,
|
||||
ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5,
|
||||
surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag'
|
||||
allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null,
|
||||
forbidOverlap: opts.forbidOverlap !== false,
|
||||
grid: opts.grid != null ? Number(opts.grid) : 1,
|
||||
rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90,
|
||||
targetZone: opts.targetZone || null, // ref-строка примитива-зоны
|
||||
showZoneOutline: opts.showZoneOutline !== false,
|
||||
showArrowFrom: opts.showArrowFrom || null, // 'player' | ref
|
||||
cost: Number(opts.cost) || 0,
|
||||
currency: opts.currency || 'rubles',
|
||||
hint: opts.hint || '',
|
||||
hintError: opts.hintError || 'Разместите в отмеченном месте!',
|
||||
placedType: opts.placedType || null,
|
||||
chainPlace: !!opts.chainPlace,
|
||||
maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0,
|
||||
maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0,
|
||||
forceCameraMode: opts.forceCameraMode !== false,
|
||||
freezePlayer: !!opts.freezePlayer,
|
||||
previewPulse: opts.previewPulse !== false,
|
||||
};
|
||||
|
||||
const id = 'placement_' + (++this._placementSeq);
|
||||
const preview = this._createPreview(o);
|
||||
|
||||
this._active = {
|
||||
id, itemKey, opts: o, preview,
|
||||
rotationY: 0,
|
||||
valid: false,
|
||||
pos: new Vector3(0, 0, 0),
|
||||
zoneOutline: null,
|
||||
arrowFxRef: null,
|
||||
placedCount: 0,
|
||||
pulseT: 0,
|
||||
prevCameraMode: null,
|
||||
prevFrozen: null,
|
||||
};
|
||||
|
||||
// Зона размещения — красный контур по AABB.
|
||||
if (o.targetZone && o.showZoneOutline) this._createZoneOutline();
|
||||
// Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08).
|
||||
if (o.showArrowFrom && o.targetZone) this._createArrow();
|
||||
// Камера: placement требует видимый курсор — в first переводим в third.
|
||||
if (o.forceCameraMode) this._forceThirdCamera();
|
||||
// Заморозка игрока (опция).
|
||||
if (o.freezePlayer) this._setPlayerFrozen(true);
|
||||
|
||||
// HUD: подсказки снизу-справа + верхний hint. Сообщаем движку.
|
||||
this._emitHud(true);
|
||||
|
||||
this._startTick();
|
||||
return id;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (!this._active) return;
|
||||
const cb = this._onCancel;
|
||||
this._teardown(true);
|
||||
if (typeof cb === 'function') cb();
|
||||
}
|
||||
|
||||
/** Поставить на текущей позиции (как ЛКМ). */
|
||||
confirm() {
|
||||
const a = this._active;
|
||||
if (!a) return false;
|
||||
if (!a.valid) {
|
||||
// Невалидно — звук «не получилось» + мигание preview в красный.
|
||||
this._playFail();
|
||||
this._flashInvalid();
|
||||
return false;
|
||||
}
|
||||
// Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом
|
||||
// поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором —
|
||||
// ровно туда, где показывалось превью. Для куба-превью offset = 0.
|
||||
let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0;
|
||||
if (ox || oz) {
|
||||
const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY);
|
||||
const rx = ox * c - oz * s;
|
||||
const rz = ox * s + oz * c;
|
||||
ox = rx; oz = rz;
|
||||
}
|
||||
const result = {
|
||||
itemKey: a.itemKey,
|
||||
position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz },
|
||||
rotationY: a.rotationY,
|
||||
};
|
||||
// Списание стоимости (если задана и есть валюта-хелпер в движке).
|
||||
if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost);
|
||||
a.placedCount++;
|
||||
this._playPlace();
|
||||
|
||||
if (typeof this._onPlace === 'function') this._onPlace(result);
|
||||
|
||||
if (a.opts.chainPlace) {
|
||||
// Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем.
|
||||
// Просто продолжаем тик; valid пересчитается в следующем кадре.
|
||||
return true;
|
||||
}
|
||||
this._teardown(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Повернуть preview на N градусов вокруг Y. */
|
||||
rotate(deg) {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90;
|
||||
a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2);
|
||||
if (a.preview) a.preview.rotation.y = a.rotationY;
|
||||
}
|
||||
|
||||
// ── Внутреннее ──────────────────────────────────────────────────────
|
||||
|
||||
_createPreview(o) {
|
||||
const base = Color3.FromHexString(o.previewColor || '#a0522d');
|
||||
|
||||
// Для воксельной модели (user:<id>) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ
|
||||
// модели — полупрозрачную копию. Так тень точно повторяет форму предмета
|
||||
// И совпадает по позиционированию с реальным spawn (модель растёт от угла
|
||||
// root, а не центрируется — куб-превью раньше центрировался → предмет
|
||||
// вставал в угол превью). Здесь превью = тот же addInstance, поэтому
|
||||
// угол-в-угол. Делается асинхронно (см. _buildUserModelPreview).
|
||||
const pt = o.previewType || '';
|
||||
if (pt.indexOf('user:') === 0 && this.s.userModelManager) {
|
||||
// Временный куб-заглушка пока модель грузится (1-2 кадра), заменим.
|
||||
const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene);
|
||||
stub.isPickable = false;
|
||||
stub._baseColor = base;
|
||||
this._buildUserModelPreview(pt, o, base);
|
||||
return stub;
|
||||
}
|
||||
|
||||
// Примитивы / прочее — полупрозрачный куб размером previewScale (юниты).
|
||||
const edge = Number(o.previewScale) || 1;
|
||||
const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene);
|
||||
const mat = new StandardMaterial('placementGhostMat', this.scene);
|
||||
mat.diffuseColor = base;
|
||||
mat.emissiveColor = base.scale(0.25);
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
mat.alpha = o.ghostOpacity;
|
||||
mat.disableLighting = true;
|
||||
ghost.material = mat;
|
||||
ghost.isPickable = false;
|
||||
ghost._baseColor = base;
|
||||
return ghost;
|
||||
}
|
||||
|
||||
/** Построить полупрозрачное превью из реальной воксельной модели (async). */
|
||||
async _buildUserModelPreview(previewType, o, base) {
|
||||
try {
|
||||
const um = this.s.userModelManager;
|
||||
// Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью.
|
||||
const instId = await um.addInstance(previewType, 0, 0, 0, 0, {
|
||||
scale: o.modelScale || o.previewScale || 1,
|
||||
canCollide: false, visible: true, anchored: true,
|
||||
currentUserId: this.s._currentUserId || null,
|
||||
});
|
||||
if (instId == null) return;
|
||||
// Сессия уже могла завершиться/смениться, пока грузилось.
|
||||
const a = this._active;
|
||||
if (!a) { try { um.removeInstance(instId); } catch (e) {} return; }
|
||||
const inst = um.instances.get(instId);
|
||||
if (!inst || !inst.rootNode) return;
|
||||
// Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable.
|
||||
const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene);
|
||||
ghostMat.diffuseColor = base;
|
||||
ghostMat.emissiveColor = base.scale(0.25);
|
||||
ghostMat.specularColor = new Color3(0, 0, 0);
|
||||
ghostMat.alpha = o.ghostOpacity;
|
||||
ghostMat.disableLighting = true;
|
||||
ghostMat.backFaceCulling = false;
|
||||
for (const m of (inst.meshes || [])) {
|
||||
m.isPickable = false;
|
||||
m.material = ghostMat;
|
||||
}
|
||||
// Центр модели по X/Z (воксели растут углом от root → центр смещён).
|
||||
// Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0).
|
||||
// Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр
|
||||
// по X/Z) было ровно под курсором, а не угол. Применяется и к превью,
|
||||
// и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали.
|
||||
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
|
||||
for (const m of (inst.meshes || [])) {
|
||||
m.computeWorldMatrix(true);
|
||||
const bb = m.getBoundingInfo().boundingBox;
|
||||
minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x);
|
||||
minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z);
|
||||
}
|
||||
const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0;
|
||||
const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0;
|
||||
a._modelOffsetX = offX;
|
||||
a._modelOffsetZ = offZ;
|
||||
|
||||
// Удаляем временный stub, новый root становится превью.
|
||||
const old = a.preview;
|
||||
a.preview = inst.rootNode;
|
||||
a.preview._baseColor = base;
|
||||
a.preview._userModelInstId = instId; // для teardown
|
||||
a.preview._ghostMat = ghostMat;
|
||||
if (old) { try { old.dispose(); } catch (e) {} }
|
||||
} catch (e) {
|
||||
// тихо — превью некритично, останется stub
|
||||
}
|
||||
}
|
||||
|
||||
_startTick() {
|
||||
this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick());
|
||||
}
|
||||
|
||||
_tick() {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
const scn = this.scene;
|
||||
|
||||
// Raycast от камеры через текущую позицию курсора.
|
||||
const pick = scn.pick(scn.pointerX, scn.pointerY, (m) =>
|
||||
m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts));
|
||||
if (pick && pick.hit && pick.pickedPoint) {
|
||||
let p = pick.pickedPoint.clone();
|
||||
// surfaceMode 'ground' — нормаль должна смотреть вверх.
|
||||
// Поверхность валидна, если смотрит вверх (горизонтальная грань).
|
||||
// Это и пол, и ВЕРХ другого объекта → можно строить стопкой.
|
||||
let surfOk = true;
|
||||
if (a.opts.surfaceMode === 'ground') {
|
||||
const n = pick.getNormal(true);
|
||||
surfOk = n && n.y > 0.6; // только грань, обращённая вверх
|
||||
}
|
||||
// Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект
|
||||
// лёг ровно сверху на пол ИЛИ на другой объект (стопка).
|
||||
if (a.opts.grid > 0) {
|
||||
p.x = Math.round(p.x / a.opts.grid) * a.opts.grid;
|
||||
p.z = Math.round(p.z / a.opts.grid) * a.opts.grid;
|
||||
}
|
||||
a.pos.copyFrom(p);
|
||||
if (a.preview) {
|
||||
if (a.preview._userModelInstId != null) {
|
||||
// userModel-превью: root = угол модели. Вычитаем offset центра
|
||||
// по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором.
|
||||
// Высота p.y без сдвига (низ модели на поверхность).
|
||||
a.preview.position.set(
|
||||
p.x - (a._modelOffsetX || 0),
|
||||
p.y,
|
||||
p.z - (a._modelOffsetZ || 0),
|
||||
);
|
||||
} else {
|
||||
// Куб-превью центрирован → поднимаем на полвысоты.
|
||||
a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z);
|
||||
}
|
||||
}
|
||||
// Валидность. forbidOverlap теперь означает «не врезаться вбок в
|
||||
// объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена.
|
||||
a.valid = surfOk
|
||||
&& this._inZone(p, a.opts)
|
||||
&& this._distanceOk(p, a.opts)
|
||||
&& this._limitOk(a.opts)
|
||||
&& this._affordable(a)
|
||||
&& (!a.opts.forbidOverlap || !this._overlapsSide(p, a));
|
||||
} else {
|
||||
a.valid = false;
|
||||
}
|
||||
|
||||
// Цвет preview: зелёный/красный.
|
||||
this._applyTint(a, a.valid);
|
||||
|
||||
// Пульсация прозрачности (привлекает внимание). Материал — у куба-превью
|
||||
// напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat).
|
||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
||||
if (a.opts.previewPulse && pmat) {
|
||||
a.pulseT += this.scene.getEngine().getDeltaTime() / 1000;
|
||||
const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1
|
||||
pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k);
|
||||
}
|
||||
|
||||
// HUD-индикатор ошибки (красный текст когда невалидно).
|
||||
this._emitHudError(!a.valid);
|
||||
|
||||
// Стрелка к зоне — обновим конечную точку (если игрок движется).
|
||||
if (a.arrowFxRef) this._updateArrow();
|
||||
|
||||
// onMove колбэк автору (каждый кадр).
|
||||
if (typeof this._onMove === 'function') {
|
||||
this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid });
|
||||
}
|
||||
}
|
||||
|
||||
_applyTint(a, valid) {
|
||||
// Материал куба-превью напрямую, userModel-превью — в _ghostMat.
|
||||
const pmat = a.preview && (a.preview.material || a.preview._ghostMat);
|
||||
if (!pmat) return;
|
||||
if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) {
|
||||
return; // во время flash держим красный
|
||||
}
|
||||
const tint = valid ? VALID_TINT : INVALID_TINT;
|
||||
// Смешиваем базовый цвет с tint-ом (multiply-эффект).
|
||||
const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25);
|
||||
pmat.diffuseColor = new Color3(
|
||||
b.r * tint.r + tint.r * 0.4,
|
||||
b.g * tint.g + tint.g * 0.4,
|
||||
b.b * tint.b + tint.b * 0.4,
|
||||
);
|
||||
pmat.emissiveColor = tint.scale(0.35);
|
||||
}
|
||||
|
||||
_flashInvalid() {
|
||||
const a = this._active;
|
||||
if (!a || !a.preview || !a.preview.material) return;
|
||||
try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; }
|
||||
a.preview.material.diffuseColor = INVALID_TINT;
|
||||
a.preview.material.emissiveColor = INVALID_TINT.scale(0.6);
|
||||
}
|
||||
|
||||
_isSurface(mesh, o) {
|
||||
if (!o.allowSurfaces) return true; // любая поверхность
|
||||
// Совпадение по имени или тегу.
|
||||
const name = mesh.name || '';
|
||||
if (o.allowSurfaces.some(s => name.includes(s))) return true;
|
||||
const tags = mesh.metadata && mesh.metadata.tags;
|
||||
if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
_inZone(p, o) {
|
||||
if (!o.targetZone) return true;
|
||||
const z = this._resolveZoneMesh(o.targetZone);
|
||||
if (!z) return true;
|
||||
const bb = z.getBoundingInfo().boundingBox;
|
||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
||||
return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z;
|
||||
}
|
||||
|
||||
_distanceOk(p, o) {
|
||||
if (!o.maxDistance || o.maxDistance <= 0) return true;
|
||||
const pl = this.s.player && this.s.player._pos;
|
||||
if (!pl) return true;
|
||||
const dx = p.x - pl.x, dz = p.z - pl.z;
|
||||
return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance;
|
||||
}
|
||||
|
||||
_limitOk(o) {
|
||||
if (!o.maxItems || o.maxItems <= 0) return true;
|
||||
return (this._active.placedCount || 0) < o.maxItems;
|
||||
}
|
||||
|
||||
_overlapsSide(p, a) {
|
||||
// Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте
|
||||
// (его тело пересекает уровень, куда ляжет новый объект). Объект строго
|
||||
// НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет
|
||||
// строить башню из кубов, но не даёт двум кубам слипнуться вбок.
|
||||
const r = Math.max(0.45, (a.opts.grid || 1) * 0.5);
|
||||
const newY = p.y; // высота поверхности (низ нового объекта)
|
||||
const newTop = newY + (a.opts.previewScale || 1);
|
||||
for (const m of this.scene.meshes) {
|
||||
if (!m.isPickable || m === a.preview) continue;
|
||||
if (!m.getBoundingInfo) continue;
|
||||
const bb = m.getBoundingInfo().boundingBox;
|
||||
const sizeX = bb.maximumWorld.x - bb.minimumWorld.x;
|
||||
if (sizeX > 8) continue; // пол/большая поверхность — не препятствие
|
||||
const c = bb.centerWorld;
|
||||
const dx = c.x - p.x, dz = c.z - p.z;
|
||||
if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором
|
||||
const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y;
|
||||
// Пересечение по вертикали: тела перекрываются по Y → бок в бок.
|
||||
const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05);
|
||||
if (overlapY) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Хватает ли валюты на текущий предмет (если задан баланс). */
|
||||
_affordable(a) {
|
||||
const cur = a.opts.currency;
|
||||
const cost = a.opts.cost || 0;
|
||||
if (!cost) return true;
|
||||
const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity;
|
||||
return cost <= bal;
|
||||
}
|
||||
|
||||
/** Установить баланс валюты (для проверки «нельзя уйти в минус»). */
|
||||
setBalance(currency, amount) {
|
||||
if (!this._balances) this._balances = {};
|
||||
if (currency) this._balances[currency] = Number(amount) || 0;
|
||||
}
|
||||
|
||||
_resolveZoneMesh(ref) {
|
||||
// ref может быть строкой ('primitive:N' / имя) или уже мешем.
|
||||
if (ref && ref.getBoundingInfo) return ref;
|
||||
if (typeof ref === 'string') {
|
||||
// через scene3d — найти примитив/модель по ref
|
||||
try {
|
||||
const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null;
|
||||
if (mesh) return mesh;
|
||||
} catch { /* ignore */ }
|
||||
// fallback — по имени
|
||||
return this.scene.getMeshByName(ref) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_createZoneOutline() {
|
||||
const a = this._active;
|
||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
||||
if (!z) return;
|
||||
const bb = z.getBoundingInfo().boundingBox;
|
||||
const min = bb.minimumWorld, max = bb.maximumWorld;
|
||||
const y = min.y + 0.06;
|
||||
const pts = [
|
||||
new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z),
|
||||
new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z),
|
||||
new Vector3(min.x, y, min.z),
|
||||
];
|
||||
const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene);
|
||||
line.color = new Color3(1, 0.19, 0.19);
|
||||
line.isPickable = false;
|
||||
// glow-имитация: чуть приподнятая полупрозрачная плоскость
|
||||
a.zoneOutline = line;
|
||||
}
|
||||
|
||||
_createArrow() {
|
||||
const a = this._active;
|
||||
// Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget).
|
||||
try {
|
||||
const bm = this.s.beamManager;
|
||||
if (!bm || !bm.addPointer) return;
|
||||
const z = this._resolveZoneMesh(a.opts.targetZone);
|
||||
if (!z) return;
|
||||
const c = z.getBoundingInfo().boundingBox.centerWorld;
|
||||
const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos)
|
||||
? this.s.player._pos
|
||||
: this._resolveZoneMesh(a.opts.showArrowFrom);
|
||||
const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null;
|
||||
if (!fromV) return;
|
||||
a.arrowFxRef = bm.addPointer({
|
||||
from: { x: fromV.x, y: fromV.y, z: fromV.z },
|
||||
to: { x: c.x, y: c.y + 0.6, z: c.z },
|
||||
preset: 'guide',
|
||||
});
|
||||
} catch { /* стрелка не критична */ }
|
||||
}
|
||||
|
||||
_updateArrow() {
|
||||
// Стрелка статична от точки старта к зоне (как в Roblox tycoon —
|
||||
// указатель «куда ставить»). BeamManager не имеет setPointerOrigin,
|
||||
// а пересоздавать каждый кадр дорого. Конец уже привязан к зоне.
|
||||
}
|
||||
|
||||
_forceThirdCamera() {
|
||||
const a = this._active;
|
||||
try {
|
||||
if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) {
|
||||
a.prevCameraMode = this.s.player.getCameraMode();
|
||||
if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_setPlayerFrozen(frozen) {
|
||||
try {
|
||||
if (this.s.player && this.s.player.setFrozen) {
|
||||
if (this._active) this._active.prevFrozen = true;
|
||||
this.s.player.setFrozen(frozen);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_spendCurrency(currency, amount) {
|
||||
// Движок не держит «кошелёк» — это делает игра через onPlace + save.
|
||||
// Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет).
|
||||
try {
|
||||
if (this.s.spendCurrency) this.s.spendCurrency(currency, amount);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } }
|
||||
_playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } }
|
||||
|
||||
_emitHud(show) {
|
||||
// Сообщаем движку показать/скрыть placement-HUD (подсказки).
|
||||
try {
|
||||
if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_emitHudError(isError) {
|
||||
try {
|
||||
if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
_teardown(emitHudOff) {
|
||||
const a = this._active;
|
||||
if (!a) return;
|
||||
if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; }
|
||||
if (a.preview) {
|
||||
try {
|
||||
if (a.preview._userModelInstId != null && this.s.userModelManager) {
|
||||
// userModel-превью — это реальный инстанс; удаляем через менеджер
|
||||
// (снимет из Map + dispose мешей). + чистим ghost-материал.
|
||||
try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {}
|
||||
this.s.userModelManager.removeInstance(a.preview._userModelInstId);
|
||||
} else {
|
||||
a.preview.material && a.preview.material.dispose();
|
||||
a.preview.dispose();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } }
|
||||
if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) {
|
||||
try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ }
|
||||
}
|
||||
if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) {
|
||||
try { this.s.player.setCameraMode('first'); } catch { /* ignore */ }
|
||||
}
|
||||
if (a.prevFrozen && this.s.player && this.s.player.setFrozen) {
|
||||
try { this.s.player.setFrozen(false); } catch { /* ignore */ }
|
||||
}
|
||||
this._active = null;
|
||||
if (emitHudOff !== false) this._emitHud(false);
|
||||
}
|
||||
|
||||
/** Полный сброс при Stop игры. */
|
||||
dispose() {
|
||||
this._teardown(true);
|
||||
this._onPlace = this._onCancel = this._onMove = null;
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
|
||||
@ -65,6 +65,11 @@ let _globalNpcDeathHandlers = [];
|
||||
let _inventory = { slots: [], activeIndex: 0 };
|
||||
// Подписки game.player.onToolUse(fn).
|
||||
let _toolUseHandlers = [];
|
||||
// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove.
|
||||
let _placeOnPlaceHandlers = [];
|
||||
let _placeOnCancelHandlers = [];
|
||||
let _placeOnMoveHandlers = [];
|
||||
let _invUiSlotClickHandlers = [];
|
||||
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
|
||||
let _players = { me: null, list: [] };
|
||||
// Общее состояние комнаты game.room.get/set — зеркало из main thread.
|
||||
@ -74,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 = {};
|
||||
@ -97,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')
|
||||
@ -421,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;
|
||||
},
|
||||
@ -642,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) {
|
||||
@ -728,6 +747,10 @@ const game = {
|
||||
_send('player.teleport', { x: nx, y: ny, z: nz });
|
||||
}
|
||||
},
|
||||
/** Заблокировать/разблокировать управление игроком (WASD/прыжок). */
|
||||
setInputBlocked(blocked) {
|
||||
_send('player.setInputBlocked', { blocked: !!blocked });
|
||||
},
|
||||
/** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров —
|
||||
* смена полосы без отмены продвижения autorun. */
|
||||
setLaneX(x) {
|
||||
@ -1276,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);
|
||||
@ -1453,12 +1478,24 @@ const game = {
|
||||
subType: 'user:' + subType,
|
||||
x, y, z,
|
||||
rotationY: opts.rotationY,
|
||||
scale: opts.scale,
|
||||
name: opts.name,
|
||||
ref,
|
||||
});
|
||||
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. */
|
||||
@ -2190,6 +2227,140 @@ const game = {
|
||||
_send('camera.reset', {});
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Главное меню игры (задача 13) — оркестратор поверх camera.cutscene + gui
|
||||
* + audio + loading. Живая 3D-сцена + cinematic-камера + GUI + музыка;
|
||||
* клик ИГРАТЬ → переход в игру.
|
||||
*/
|
||||
mainMenu: {
|
||||
_onShow: [], _onPlay: [], _onHide: [],
|
||||
_active: false,
|
||||
_curMusic: null,
|
||||
_opts: null,
|
||||
_camCfg: null,
|
||||
_loopArmed: false,
|
||||
show(opts) {
|
||||
opts = opts && typeof opts === 'object' ? opts : {};
|
||||
this._opts = opts;
|
||||
this._active = true;
|
||||
_send('player.setInputBlocked', { blocked: true });
|
||||
game.hud.setVisible(false);
|
||||
this._camCfg = opts.camera || { mode: 'orbit', center: { x: 0, y: 1, z: 0 }, radius: 6, height: 2, duration: 12 };
|
||||
this.setCamera(this._camCfg);
|
||||
if (!this._loopArmed) {
|
||||
this._loopArmed = true;
|
||||
const self = this;
|
||||
game.onCutsceneDone(() => {
|
||||
if (self._active && self._camCfg) self.setCamera(self._camCfg);
|
||||
});
|
||||
}
|
||||
this._buildGui(opts);
|
||||
if (opts.music && typeof opts.music === 'string') {
|
||||
this._curMusic = opts.music;
|
||||
_send('audio.playMusic', { trackId: opts.music });
|
||||
}
|
||||
for (const fn of this._onShow) _safeCall(fn, undefined, 'mainMenu.onShow');
|
||||
},
|
||||
setCamera(cam) {
|
||||
cam = cam && typeof cam === 'object' ? cam : {};
|
||||
if (this._active) this._camCfg = cam;
|
||||
const mode = cam.mode || 'orbit';
|
||||
const dur = Number.isFinite(Number(cam.duration)) ? Number(cam.duration) : 12;
|
||||
if (mode === 'static') {
|
||||
const p = cam.position || { x: 0, y: 5, z: 8 };
|
||||
const t = cam.target || { x: 0, y: 1, z: 0 };
|
||||
_send('camera.cutscene', { points: [p, p], lookAt: [t, t], segDuration: 9999 });
|
||||
return;
|
||||
}
|
||||
if (mode === 'orbit') {
|
||||
const c = cam.center || { x: 0, y: 1, z: 0 };
|
||||
const r = Number.isFinite(Number(cam.radius)) ? Number(cam.radius) : 6;
|
||||
const h = Number.isFinite(Number(cam.height)) ? Number(cam.height) : 2;
|
||||
const N = 16;
|
||||
const pts = [], looks = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const a = (i / N) * Math.PI * 2;
|
||||
pts.push({ x: c.x + Math.cos(a) * r, y: c.y + h, z: c.z + Math.sin(a) * r });
|
||||
looks.push({ x: c.x, y: c.y, z: c.z });
|
||||
}
|
||||
_send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / N });
|
||||
return;
|
||||
}
|
||||
if (mode === 'preset-cuts') {
|
||||
const cuts = Array.isArray(cam.cuts) ? cam.cuts : [];
|
||||
if (cuts.length < 1) return;
|
||||
const pts = [], looks = [];
|
||||
for (const c of cuts) {
|
||||
const p = c.position || { x: 0, y: 3, z: 6 };
|
||||
const t = c.target || { x: 0, y: 1, z: 0 };
|
||||
pts.push(p, p); looks.push(t, t);
|
||||
}
|
||||
_send('camera.cutscene', { points: pts, lookAt: looks, segDuration: (cuts[0].duration || 2) });
|
||||
return;
|
||||
}
|
||||
const wps = Array.isArray(cam.waypoints) ? cam.waypoints : [];
|
||||
if (wps.length < 2) return;
|
||||
const pts = wps.map(w => w.position || { x: 0, y: 2, z: 0 });
|
||||
const looks = wps.map(w => w.target || { x: 0, y: 1, z: 0 });
|
||||
_send('camera.cutscene', { points: pts, lookAt: looks, segDuration: dur / Math.max(1, pts.length - 1) });
|
||||
},
|
||||
setPatchNotes(pn) {
|
||||
pn = pn && typeof pn === 'object' ? pn : {};
|
||||
const title = (pn.title || '') + (pn.version ? ' (' + pn.version + '):' : ':');
|
||||
const items = Array.isArray(pn.items) ? pn.items : [];
|
||||
game.gui.create('text', {
|
||||
id: '_mm_pn_title', x: 78, y: 28, w: 40, h: 6, anchor: 'center',
|
||||
text: title, textColor: '#ffffff', textSize: 26, fontWeight: 900,
|
||||
textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0,
|
||||
});
|
||||
items.slice(0, 7).forEach((it, i) => {
|
||||
game.gui.create('text', {
|
||||
id: '_mm_pn_' + i, x: 78, y: 36 + i * 6, w: 40, h: 5, anchor: 'center',
|
||||
text: '- ' + it, textColor: '#e8edf5', textSize: 20, fontWeight: 700,
|
||||
textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0,
|
||||
});
|
||||
});
|
||||
},
|
||||
_buildGui(opts) {
|
||||
if (opts.title) {
|
||||
game.gui.create('text', {
|
||||
id: '_mm_logo', x: 30, y: 12, w: 56, h: 12, anchor: 'center',
|
||||
text: String(opts.title), textColor: '#ffd23a', textSize: 48, fontWeight: 900,
|
||||
textStroke: { color: '#000', width: 4 }, bgColor: 'transparent', bgOpacity: 0,
|
||||
animationPreset: 'glow',
|
||||
});
|
||||
}
|
||||
if (opts.patchNotes) this.setPatchNotes(opts.patchNotes);
|
||||
game.gui.create('button', {
|
||||
id: '_mm_play', x: 84, y: 90, w: 28, h: 11, anchor: 'center',
|
||||
text: opts.playButtonText || 'ИГРАТЬ',
|
||||
bgGradient: { stops: ['#ffe066', '#e0a000'], angle: 90 },
|
||||
textColor: '#3a2a00', textSize: 30, fontWeight: 900, borderRadius: 14,
|
||||
textStroke: { color: '#fff7d0', width: 1 },
|
||||
hover: { scale: 1.06, brightness: 1.1 }, active: { scale: 0.96 },
|
||||
});
|
||||
const self = this;
|
||||
game.gui.onClick('_mm_play', () => {
|
||||
for (const fn of self._onPlay) _safeCall(fn, undefined, 'mainMenu.onPlay');
|
||||
});
|
||||
},
|
||||
hide() {
|
||||
if (!this._active) return;
|
||||
this._active = false;
|
||||
this._camCfg = null;
|
||||
const ids = ['_mm_logo', '_mm_play', '_mm_pn_title', '_mm_pn_0', '_mm_pn_1', '_mm_pn_2', '_mm_pn_3', '_mm_pn_4', '_mm_pn_5', '_mm_pn_6'];
|
||||
for (const id of ids) { try { game.gui.remove(id); } catch (e) {} }
|
||||
if (this._curMusic) { _send('audio.stopMusic', {}); this._curMusic = null; }
|
||||
_send('camera.reset', {});
|
||||
_send('player.setInputBlocked', { blocked: false });
|
||||
game.hud.setVisible(true);
|
||||
for (const fn of this._onHide) _safeCall(fn, undefined, 'mainMenu.onHide');
|
||||
},
|
||||
onShow(fn) { if (typeof fn === 'function') this._onShow.push(fn); },
|
||||
onPlay(fn) { if (typeof fn === 'function') this._onPlay.push(fn); },
|
||||
onHide(fn) { if (typeof fn === 'function') this._onHide.push(fn); },
|
||||
isActive() { return this._active; },
|
||||
},
|
||||
/**
|
||||
* Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат).
|
||||
* Нужно для игр которые делают свой UI через game.gui.* и не хотят
|
||||
@ -2478,6 +2649,55 @@ const game = {
|
||||
return m;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Экран загрузки (задача 12) — программный mid-game transition.
|
||||
* const lo = game.loading.show({ progressBar:true, spinner:true });
|
||||
* lo.setProgress(0.5); lo.close();
|
||||
* await game.loading.transition({ cover:{sceneSnapshot:true}, duration:4 });
|
||||
* Хэндл возвращается синхронно (локальный id). Колбэки onSkip/onComplete
|
||||
* приходят через globalEvent (loadingSkip/loadingComplete) — см. ниже.
|
||||
*/
|
||||
loading: {
|
||||
_localSeq: 0,
|
||||
_localToReal: new Map(), // localId → реальный loadingId (приходит в loadingShown)
|
||||
_handlers: new Map(), // localId → { onSkip:[], onComplete:[] }
|
||||
show(opts) {
|
||||
opts = opts && typeof opts === 'object' ? opts : {};
|
||||
const localId = ++this._localSeq;
|
||||
const replyId = '_lshow_' + localId;
|
||||
const h = { onSkip: [], onComplete: [] };
|
||||
if (typeof opts.onSkip === 'function') h.onSkip.push(opts.onSkip);
|
||||
if (typeof opts.onComplete === 'function') h.onComplete.push(opts.onComplete);
|
||||
this._handlers.set(localId, h);
|
||||
// Функции нельзя слать в main — вырезаем перед _send.
|
||||
const safe = {};
|
||||
for (const k in opts) { if (typeof opts[k] !== 'function') safe[k] = opts[k]; }
|
||||
_send('loading.show', { opts: safe, replyId });
|
||||
const self = this;
|
||||
return {
|
||||
_localId: localId,
|
||||
setProgress(v) { _send('loading.setProgress', { localId, value: Number(v) || 0 }); },
|
||||
setText(t) { _send('loading.setText', { localId, text: String(t == null ? '' : t) }); },
|
||||
setCover(c) { _send('loading.setCover', { localId, cover: c }); },
|
||||
close() { _send('loading.close', { localId }); },
|
||||
onSkip(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onSkip.push(fn); },
|
||||
onComplete(fn) { if (typeof fn === 'function') self._handlers.get(localId)?.onComplete.push(fn); },
|
||||
};
|
||||
},
|
||||
/** Типовой переход с фейковым прогрессом. Возвращает Promise (резолв по complete/skip). */
|
||||
transition(opts) {
|
||||
opts = opts && typeof opts === 'object' ? { ...opts } : {};
|
||||
if (!Number.isFinite(opts.duration) || opts.duration <= 0) opts.duration = 3;
|
||||
const self = this;
|
||||
return new Promise((resolve) => {
|
||||
const h = self.show(opts);
|
||||
let done = false;
|
||||
const finish = () => { if (done) return; done = true; resolve(); };
|
||||
h.onComplete(finish);
|
||||
h.onSkip(finish);
|
||||
});
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar.
|
||||
* game.inventory.add({ name: 'Зелье', kind: 'item' })
|
||||
@ -3150,6 +3370,70 @@ const game = {
|
||||
return a + Math.random() * (b - a);
|
||||
},
|
||||
|
||||
// Форматирование чисел/времени/денег для UI. Портировано из студии
|
||||
// (задача 11 — игра «Мой завод» использует game.format.money).
|
||||
format: {
|
||||
time(seconds, fmt) {
|
||||
let sec = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
const p2 = (n) => String(n).padStart(2, '0');
|
||||
if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s);
|
||||
if (fmt === 'mm:ss') {
|
||||
const tm = Math.floor(sec / 60);
|
||||
return p2(tm) + ':' + p2(s);
|
||||
}
|
||||
// auto
|
||||
if (h > 0) return h + 'ч ' + m + 'м';
|
||||
if (m > 0) return m + 'м ' + s + 'с';
|
||||
return s + 'с';
|
||||
},
|
||||
number(n, fmt) {
|
||||
n = Number(n) || 0;
|
||||
if (fmt === 'percent') return Math.round(n * 100) + '%';
|
||||
if (fmt === 'short') {
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B';
|
||||
if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M';
|
||||
if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K';
|
||||
return String(Math.round(n));
|
||||
}
|
||||
// comma — пробелы-разделители тысяч (русский стиль), без regex.
|
||||
const str = String(Math.abs(Math.round(n)));
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (i > 0 && (str.length - i) % 3 === 0) out += ' ';
|
||||
out += str[i];
|
||||
}
|
||||
return (n < 0 ? '-' : '') + out;
|
||||
},
|
||||
money(amount, unit) {
|
||||
const num = this.number(amount, 'comma');
|
||||
const u = (unit === 'rubles' || unit === undefined)
|
||||
? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов')
|
||||
: unit;
|
||||
return num + ' ' + u;
|
||||
},
|
||||
duration(seconds) {
|
||||
let sec = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов');
|
||||
if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут');
|
||||
return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд');
|
||||
},
|
||||
// Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов).
|
||||
_plural(n, one, few, many) {
|
||||
n = Math.abs(n) % 100;
|
||||
const n1 = n % 10;
|
||||
if (n > 10 && n < 20) return many;
|
||||
if (n1 > 1 && n1 < 5) return few;
|
||||
if (n1 === 1) return one;
|
||||
return many;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Расстояние между двумя точками или объектами.
|
||||
* Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex).
|
||||
@ -3231,6 +3515,87 @@ const game = {
|
||||
const na = Number(a), nb = Number(b), nt = Number(t);
|
||||
return na + (nb - na) * nt;
|
||||
},
|
||||
/**
|
||||
* game.placement — drag-and-drop размещение объектов (задача 11).
|
||||
* Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором
|
||||
* → ЛКМ ставит». См. 11_placement_mode.md.
|
||||
*
|
||||
* game.placement.start('crate', {
|
||||
* previewType: 'model:crate', surfaceMode: 'ground', grid: 1,
|
||||
* cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'),
|
||||
* showArrowFrom: 'player', showZoneOutline: true, chainPlace: true,
|
||||
* });
|
||||
* game.placement.onPlace(({ itemKey, position, rotationY }) => { ... });
|
||||
*/
|
||||
placement: {
|
||||
/** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */
|
||||
start(itemKey, opts) {
|
||||
if (typeof itemKey !== 'string' || !itemKey) return null;
|
||||
const o = opts && typeof opts === 'object' ? opts : {};
|
||||
// targetZone может прийти как ref-объект findOne — нормализуем в строку.
|
||||
const out = { itemKey, opts: { ...o } };
|
||||
if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone;
|
||||
_send('placement.start', out);
|
||||
return itemKey;
|
||||
},
|
||||
/** Отменить активный режим (как ПКМ/Esc). */
|
||||
cancel() { _send('placement.cancel', {}); },
|
||||
/** Поставить на текущей позиции (как ЛКМ). */
|
||||
confirm() { _send('placement.confirm', {}); },
|
||||
/** Повернуть preview на N градусов (по умолчанию rotationStep). */
|
||||
rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); },
|
||||
/** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */
|
||||
onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); },
|
||||
/** fn() — режим отменён игроком. */
|
||||
onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); },
|
||||
/** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */
|
||||
onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); },
|
||||
},
|
||||
|
||||
/**
|
||||
* game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11).
|
||||
* Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту
|
||||
* → onSlotClick(item) (обычно автор зовёт game.placement.start внутри).
|
||||
* Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance).
|
||||
*
|
||||
* game.inventoryUi.create({
|
||||
* items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }],
|
||||
* position: 'bottom', showCost: true, showCurrency: 'rubles',
|
||||
* onSlotClick: (item) => game.placement.start(item.key, {...}),
|
||||
* });
|
||||
*/
|
||||
inventoryUi: {
|
||||
/** Создать панель слотов. См. 11_placement_mode.md §2.7. */
|
||||
create(opts) {
|
||||
const o = opts && typeof opts === 'object' ? opts : {};
|
||||
const items = Array.isArray(o.items) ? o.items : [];
|
||||
if (typeof o.onSlotClick === 'function') {
|
||||
// Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}.
|
||||
_invUiSlotClickHandlers.push(o.onSlotClick);
|
||||
}
|
||||
_send('inventoryUi.create', {
|
||||
items: items.map(it => ({
|
||||
key: String(it.key || ''),
|
||||
name: String(it.name || ''),
|
||||
icon: it.icon || '',
|
||||
cost: Number(it.cost) || 0,
|
||||
modelType: it.modelType || '',
|
||||
})),
|
||||
position: o.position || 'bottom',
|
||||
slotSize: Number(o.slotSize) || 80,
|
||||
spacing: Number(o.spacing) || 4,
|
||||
showCost: o.showCost !== false,
|
||||
showCurrency: o.showCurrency || '',
|
||||
});
|
||||
},
|
||||
/** Обновить баланс валюты (для авто-серых слотов). */
|
||||
setBalance(currency, amount) {
|
||||
_send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 });
|
||||
},
|
||||
/** Скрыть/удалить панель. */
|
||||
remove() { _send('inventoryUi.remove', {}); },
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
@ -3456,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') {
|
||||
@ -3487,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');
|
||||
@ -3594,6 +3968,34 @@ self.onmessage = (e) => {
|
||||
for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose');
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (t === 'loadingShown') {
|
||||
// Задача 12: реальный loadingId от runtime — маппим local→real.
|
||||
try {
|
||||
const lo = (typeof game !== 'undefined') && game.loading;
|
||||
if (lo && payload && payload.replyId) {
|
||||
const localId = Number(String(payload.replyId).replace(/^_lshow_/, ''));
|
||||
if (Number.isFinite(localId) && payload.loadingId != null) {
|
||||
lo._localToReal.set(localId, payload.loadingId);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (t === 'loadingSkip' || t === 'loadingComplete') {
|
||||
// Игрок нажал «Пропустить» или прогресс достиг 100% — зовём подписчиков.
|
||||
try {
|
||||
const lo = (typeof game !== 'undefined') && game.loading;
|
||||
const real = payload && payload.loadingId;
|
||||
if (lo && real != null) {
|
||||
for (const [local, r] of lo._localToReal) {
|
||||
if (r === real) {
|
||||
const h = lo._handlers.get(local);
|
||||
if (h) {
|
||||
const arr = t === 'loadingSkip' ? h.onSkip : h.onComplete;
|
||||
for (const fn of arr) _safeCall(fn, undefined, 'loading.' + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (t === 'skinChanged') {
|
||||
// Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков.
|
||||
const slug = payload && payload.slug;
|
||||
@ -3604,6 +4006,17 @@ self.onmessage = (e) => {
|
||||
} else if (t === 'skinUnlocked') {
|
||||
const slug = payload && payload.slug;
|
||||
if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug);
|
||||
} else if (t === 'placeConfirm') {
|
||||
const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY };
|
||||
for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace');
|
||||
} else if (t === 'placeCancel') {
|
||||
for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel');
|
||||
} else if (t === 'placeMove') {
|
||||
const ev = { position: payload.position, valid: !!payload.valid };
|
||||
for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove');
|
||||
} else if (t === 'invUiSlotClick') {
|
||||
const item = payload.item || { key: payload.key };
|
||||
for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick');
|
||||
}
|
||||
} else if (cmd === 'sceneSnapshot') {
|
||||
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
|
||||
|
||||
132
src/engine/ShopInventoryUi.js
Normal file
132
src/engine/ShopInventoryUi.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11).
|
||||
*
|
||||
* Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover.
|
||||
* Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри
|
||||
* game.placement.start(...). Слот серый и некликабельный, если валюты мало
|
||||
* (показывается, когда заданы showCurrency + текущий баланс через setBalance).
|
||||
*
|
||||
* Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с
|
||||
* иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к
|
||||
* родителю canvas, абсолютным позиционированием.
|
||||
*
|
||||
* Фича-парность: идентичный модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
|
||||
// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI).
|
||||
const SLOT_ICONS = {
|
||||
crate: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#7a4a1e" stroke-width="1.6"><rect x="3" y="6" width="18" height="13" rx="1" fill="#c2884a"/><path d="M3 10h18M9 6v13M15 6v13" stroke="#7a4a1e"/></svg>',
|
||||
plant: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 21V11" stroke="#4a7a2e" stroke-width="2"/><path d="M12 12c-3-1-5-4-5-7 3 0 6 2 5 7zM12 11c3-1 5-3 5-6-3 0-6 1-5 6z" fill="#5aa83a"/></svg>',
|
||||
oven: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#444" stroke-width="1.4"><rect x="4" y="3" width="16" height="18" rx="1.5" fill="#9aa0a6"/><rect x="7" y="9" width="10" height="9" rx="1" fill="#3a3f44"/><circle cx="9" cy="6" r="1" fill="#444"/><circle cx="13" cy="6" r="1" fill="#444"/></svg>',
|
||||
coin: '<svg viewBox="0 0 24 24" width="34" height="34"><circle cx="12" cy="12" r="9" fill="#f5c542" stroke="#b8860b" stroke-width="1.4"/><text x="12" y="16" font-size="10" text-anchor="middle" fill="#7a5a00" font-weight="700">$</text></svg>',
|
||||
box: '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="#666" stroke-width="1.6"><path d="M12 2l9 5v10l-9 5-9-5V7z" fill="#b0b6bc"/><path d="M12 2v20M3 7l9 5 9-5" /></svg>',
|
||||
};
|
||||
|
||||
function iconSvg(name) {
|
||||
return SLOT_ICONS[name] || SLOT_ICONS.box;
|
||||
}
|
||||
|
||||
export class ShopInventoryUi {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.root = null;
|
||||
this.items = [];
|
||||
this.balance = {}; // currency → amount
|
||||
this.currency = '';
|
||||
this.showCost = true;
|
||||
this._onSlotClick = null;
|
||||
this._slotEls = [];
|
||||
}
|
||||
|
||||
create(opts, onSlotClick) {
|
||||
this.remove();
|
||||
this.items = Array.isArray(opts.items) ? opts.items : [];
|
||||
this.currency = opts.showCurrency || '';
|
||||
this.showCost = opts.showCost !== false;
|
||||
this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null;
|
||||
|
||||
const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body;
|
||||
// Контейнер должен быть position:relative чтобы absolute-панель легла поверх.
|
||||
try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ }
|
||||
|
||||
const pos = opts.position || 'bottom';
|
||||
const slotSize = Number(opts.slotSize) || 80;
|
||||
const spacing = Number(opts.spacing) || 4;
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'kbn-shop-inv';
|
||||
const sideStyle = {
|
||||
bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`,
|
||||
top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`,
|
||||
left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
||||
right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`,
|
||||
}[pos] || '';
|
||||
root.style.cssText =
|
||||
`position:absolute;display:flex;gap:${spacing}px;z-index:40;` +
|
||||
`padding:8px;border-radius:14px;` +
|
||||
`background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` +
|
||||
`box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle;
|
||||
|
||||
this.items.forEach((it, idx) => {
|
||||
const slot = document.createElement('button');
|
||||
slot.type = 'button';
|
||||
slot.dataset.key = it.key;
|
||||
slot.style.cssText =
|
||||
`width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` +
|
||||
`display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` +
|
||||
`cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` +
|
||||
`background:linear-gradient(180deg,#3a4a66,#26324a);` +
|
||||
`transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`;
|
||||
slot.innerHTML =
|
||||
`<span style="pointer-events:none">${iconSvg(it.icon)}</span>` +
|
||||
`<span style="pointer-events:none;max-width:${slotSize - 8}px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.name || ''}</span>` +
|
||||
(this.showCost && it.cost
|
||||
? `<span class="kbn-cost" style="pointer-events:none;color:#ffd23a;font-size:11px">${it.cost}${this.currency ? ' ' + this._curShort() : ''}</span>`
|
||||
: '');
|
||||
slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } };
|
||||
slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; };
|
||||
slot.onclick = () => {
|
||||
if (slot.disabled) return;
|
||||
if (this._onSlotClick) this._onSlotClick(it);
|
||||
};
|
||||
this._slotEls[idx] = slot;
|
||||
root.appendChild(slot);
|
||||
});
|
||||
|
||||
parent.appendChild(root);
|
||||
this.root = root;
|
||||
this._refreshAffordability();
|
||||
}
|
||||
|
||||
_curShort() {
|
||||
const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' };
|
||||
return map[this.currency] || this.currency;
|
||||
}
|
||||
|
||||
/** Обновить баланс валюты — слоты дороже баланса станут серыми. */
|
||||
setBalance(currency, amount) {
|
||||
if (currency) this.balance[currency] = Number(amount) || 0;
|
||||
this._refreshAffordability();
|
||||
}
|
||||
|
||||
_refreshAffordability() {
|
||||
if (!this.currency) return; // без валюты все слоты активны
|
||||
const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity;
|
||||
this.items.forEach((it, idx) => {
|
||||
const slot = this._slotEls[idx];
|
||||
if (!slot) return;
|
||||
const afford = (Number(it.cost) || 0) <= bal;
|
||||
slot.disabled = !afford;
|
||||
slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)';
|
||||
slot.style.cursor = afford ? 'pointer' : 'not-allowed';
|
||||
slot.style.opacity = afford ? '1' : '0.7';
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; }
|
||||
this._slotEls = [];
|
||||
}
|
||||
|
||||
dispose() { this.remove(); this._onSlotClick = null; }
|
||||
}
|
||||
@ -514,6 +514,10 @@ export class TerrainManager {
|
||||
const mat = new StandardMaterial(name, this.scene);
|
||||
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
|
||||
mat.specularColor = new Color3(0, 0, 0);
|
||||
// 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
|
||||
// backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
|
||||
// по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
|
||||
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
|
||||
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
||||
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
||||
// особенно заметно на светло-бежевом песке — он становится серым).
|
||||
@ -543,6 +547,12 @@ export class TerrainManager {
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
mat.alpha = def.alpha;
|
||||
} else {
|
||||
// RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
|
||||
// воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
|
||||
mat.diffuseTexture.hasAlpha = false;
|
||||
mat.useAlphaFromDiffuseTexture = false;
|
||||
mat.transparencyMode = 0;
|
||||
}
|
||||
if (Array.isArray(def.emissive)) {
|
||||
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);
|
||||
|
||||
95
src/engine/VehicleHud.js
Normal file
95
src/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(); }
|
||||
}
|
||||
249
src/engine/VehicleManager.js
Normal file
249
src/engine/VehicleManager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user