Merge pull request 'feat(11): placement mode � ����������� ��������� (tycoon)' (#15) from feat/placement-task11 into main
All checks were successful
CI / Lint (push) Successful in 59s
CI / Build (push) Successful in 1m33s
CI / Secret scan (push) Successful in 2m27s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 1m55s

This commit is contained in:
min 2026-06-02 17:24:22 +00:00
commit 61ac40ab61
7 changed files with 16330 additions and 15349 deletions

View File

@ -16,6 +16,13 @@ import Icon from './Icon';
function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) {
if (!visible) return null; 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 SLOT_COUNT = 5;
const cells = []; const cells = [];
for (let i = 0; i < SLOT_COUNT; i++) { for (let i = 0; i < SLOT_COUNT; i++) {

View File

@ -64,6 +64,8 @@ import { ZombieManager } from './ZombieManager';
import { NpcManager } from './NpcManager'; import { NpcManager } from './NpcManager';
import { ConstraintManager } from './ConstraintManager'; import { ConstraintManager } from './ConstraintManager';
import { BeamManager } from './BeamManager'; import { BeamManager } from './BeamManager';
import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi';
import { ZombieSpawnerManager } from './ZombieSpawnerManager'; import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager'; import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment'; import { Environment } from './Environment';
@ -144,6 +146,11 @@ export class BabylonScene {
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
this.constraintManager = null; // связи объектов (Фаза 5, Constraints) this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
this.beamManager = null; // лучи и следы (Фаза 5.2) this.beamManager = null; // лучи и следы (Фаза 5.2)
// Placement mode (задача 11) — фича-парность со студией.
this.placementManager = null;
this.shopInventoryUi = null;
this._PlacementManagerClass = PlacementManager;
this._ShopInventoryUiClass = ShopInventoryUi;
this.spawnerManager = null; // спавнеры зомби this.spawnerManager = null; // спавнеры зомби
this.environment = null; this.environment = null;
this.audioManager = null; this.audioManager = null;
@ -2185,6 +2192,10 @@ export class BabylonScene {
// В Play-режиме ЛКМ — клик игрока в forward-направлении. // В Play-режиме ЛКМ — клик игрока в forward-направлении.
// При pointer-lock курсор в центре; в third (свободный курсор) // При pointer-lock курсор в центре; в third (свободный курсор)
// передаём реальные координаты клика для pick по табличкам. // передаём реальные координаты клика для 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) { if (e.button === 0) {
const r = canvas.getBoundingClientRect(); const r = canvas.getBoundingClientRect();
this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); this._handlePlayClick(e.clientX - r.left, e.clientY - r.top);
@ -2383,6 +2394,10 @@ export class BabylonScene {
const onWheel = (e) => { const onWheel = (e) => {
e.preventDefault(); e.preventDefault();
if (this._isPlaying && this.placementManager && this.placementManager.isActive()) {
this.placementManager.rotate();
return;
}
const forward = this._getCameraForward(); const forward = this._getCameraForward();
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
this.camera.position.addInPlace(forward.scale(delta)); this.camera.position.addInPlace(forward.scale(delta));
@ -2419,6 +2434,10 @@ export class BabylonScene {
const key = this._normalizeKey(e); const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); 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') { if (e.code === 'KeyF') {
this._focusOnTarget(new Vector3(0, 0, 0)); this._focusOnTarget(new Vector3(0, 0, 0));
} }
@ -7515,6 +7534,10 @@ export class BabylonScene {
this.gameRuntime = null; this.gameRuntime = null;
} }
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; }
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = null; }
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением) // Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
if (this.gdLevelManager) { if (this.gdLevelManager) {
this.gdLevelManager.stop(); this.gdLevelManager.stop();

View File

@ -311,6 +311,30 @@ export class GameRuntime {
return this._skinState; 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;
}
/** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */ /** slug → _modelTypeId движка. Встроенные → 'skin_<slug>', character-* как есть. */
_resolveSkinTypeId(slug) { _resolveSkinTypeId(slug) {
if (!slug) return 'character-a'; if (!slug) return 'character-a';
@ -502,6 +526,10 @@ export class GameRuntime {
s?.modelManager?.removeInstance(Number(rest)); s?.modelManager?.removeInstance(Number(rest));
} else if (kind === 'primitive') { } else if (kind === 'primitive') {
s?.primitiveManager?.removeInstance(Number(rest)); s?.primitiveManager?.removeInstance(Number(rest));
} else if (kind === 'usermodel') {
// Воксельные модели, наспавненные скриптом (placement) —
// удаляем при Stop, иначе placed-объекты остаются. См. studio.
s?.userModelManager?.removeInstance(Number(rest));
} }
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
} }
@ -1585,6 +1613,38 @@ export class GameRuntime {
return; return;
} }
// === Beam / Trail — лучи и следы (Фаза 5.2) === // === 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; }
if (cmd === 'fx.create') { if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... } // payload: { kind: 'beam'|'trail', localRef, ... }
const bm = this.scene3d?.beamManager; const bm = this.scene3d?.beamManager;
@ -3242,6 +3302,7 @@ export class GameRuntime {
const opts = payload; const opts = payload;
const p = this.scene3d?.userModelManager?.addInstance( const p = this.scene3d?.userModelManager?.addInstance(
subType, opts.x, opts.y, opts.z, opts.rotationY || 0, 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) => { Promise.resolve(p).then((instId) => {
if (instId == null) return; if (instId == null) return;

View 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;
}
}

View File

@ -65,6 +65,11 @@ let _globalNpcDeathHandlers = [];
let _inventory = { slots: [], activeIndex: 0 }; let _inventory = { slots: [], activeIndex: 0 };
// Подписки game.player.onToolUse(fn). // Подписки game.player.onToolUse(fn).
let _toolUseHandlers = []; let _toolUseHandlers = [];
// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove.
let _placeOnPlaceHandlers = [];
let _placeOnCancelHandlers = [];
let _placeOnMoveHandlers = [];
let _invUiSlotClickHandlers = [];
// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. // Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }.
let _players = { me: null, list: [] }; let _players = { me: null, list: [] };
// Общее состояние комнаты game.room.get/set — зеркало из main thread. // Общее состояние комнаты game.room.get/set — зеркало из main thread.
@ -1453,6 +1458,7 @@ const game = {
subType: 'user:' + subType, subType: 'user:' + subType,
x, y, z, x, y, z,
rotationY: opts.rotationY, rotationY: opts.rotationY,
scale: opts.scale,
name: opts.name, name: opts.name,
ref, ref,
}); });
@ -3150,6 +3156,70 @@ const game = {
return a + Math.random() * (b - a); 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). * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex).
@ -3231,6 +3301,87 @@ const game = {
const na = Number(a), nb = Number(b), nt = Number(t); const na = Number(a), nb = Number(b), nt = Number(t);
return na + (nb - na) * nt; 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', {}); },
},
}; };
/** /**
@ -3604,6 +3755,17 @@ self.onmessage = (e) => {
} else if (t === 'skinUnlocked') { } else if (t === 'skinUnlocked') {
const slug = payload && payload.slug; const slug = payload && payload.slug;
if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(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') { } else if (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }

View 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; }
}

View File

@ -514,6 +514,10 @@ export class TerrainManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
// Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
mat.specularColor = new Color3(0, 0, 0); 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 освещал материал // Ambient ставим в белый, чтобы hemisphere-light освещал материал
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что // с любой стороны (иначе нижние/тыловые грани выходят серыми, что
// особенно заметно на светло-бежевом песке — он становится серым). // особенно заметно на светло-бежевом песке — он становится серым).
@ -543,6 +547,12 @@ export class TerrainManager {
mat.diffuseTexture.hasAlpha = true; mat.diffuseTexture.hasAlpha = true;
mat.useAlphaFromDiffuseTexture = true; mat.useAlphaFromDiffuseTexture = true;
mat.alpha = def.alpha; 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)) { if (Array.isArray(def.emissive)) {
mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]); mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);