Compare commits
No commits in common. "153bba7c5b0de04a207eabad618ab491c137e592" and "71af96d1718e6e5f28f7e538ee6570cf94d55411" have entirely different histories.
153bba7c5b
...
71af96d171
@ -333,9 +333,4 @@ export const GAMES = [
|
|||||||
desc: 'Живые 3D-надписи + витрина-лутбокс: таймер над башней, ряд подиумов с вращающимися предметами и наклонными табличками-ценниками, счётчик монет (клик +10), HP над зомби. Текст крепится плоско к грани наклонного примитива.',
|
desc: 'Живые 3D-надписи + витрина-лутбокс: таймер над башней, ряд подиумов с вращающимися предметами и наклонными табличками-ценниками, счётчик монет (клик +10), HP над зомби. Текст крепится плоско к грани наклонного примитива.',
|
||||||
mechanics: ['scene.bindLabel', 'scene.bindTimer', 'attachFace (текст на грани)', '5 пресетов (gameui/boss-hp/reward…)', 'richText <color>', 'game.format.money', 'obj.move/rotate', 'onClick объекта'],
|
mechanics: ['scene.bindLabel', 'scene.bindTimer', 'attachFace (текст на грани)', '5 пресетов (gameui/boss-hp/reward…)', 'richText <color>', 'game.format.money', 'obj.move/rotate', 'onClick объекта'],
|
||||||
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
|
previewShot: 'guide-dynamic-label-scene.png', openProjectId: 2261, ready: true },
|
||||||
{ id: 'guide-zavod', num: 58, group: 'g5', stars: 2, icon: 'cart',
|
|
||||||
title: 'Мой завод — расстановка предметов (placement)',
|
|
||||||
desc: 'Tycoon-механика «купи → поставь»: магазин-инвентарь снизу, клик по слоту → полупрозрачная тень предмета летит за курсором, ЛКМ ставит на свой участок (можно стопкой), R/колесо — поворот, ПКМ/Esc — отмена. Денег мало → слот серый. Воксельный мир: трава, деревья, пруд.',
|
|
||||||
mechanics: ['game.placement.start', 'game.inventoryUi (магазин-слоты)', 'onPlace/onCancel/onMove', 'тень-превью формой модели', 'снап к сетке + стопка', 'проверка баланса (не в минус)', 'воксельные модели + ландшафт'],
|
|
||||||
previewShot: 'guide-zavod-scene.png', openProjectId: 2345, ready: true },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -8107,154 +8107,6 @@ zombie.onClick(() => { hp = Math.max(0, hp - 10); });`}</Code>
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
'guide-zavod': {
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<h3 className="lessonH">Что получится</h3>
|
|
||||||
<p>
|
|
||||||
Маленький <b>tycoon-завод</b> на воксельном острове: трава, холмы,
|
|
||||||
деревья, пруд. Снизу — <b>магазин-инвентарь</b> из трёх слотов
|
|
||||||
(ящик, дерево, печь). Кликаешь слот → за курсором летит
|
|
||||||
<b> полупрозрачная тень</b> предмета, повторяющая его форму. ЛКМ
|
|
||||||
ставит предмет на свой участок (можно <b>класть стопкой</b>),
|
|
||||||
<b> R</b> или колесо — поворот, <b>ПКМ/Esc</b> — отмена. Денег не
|
|
||||||
хватает → слот <b>серый</b>, поставить нельзя. Это базовый
|
|
||||||
gameplay-loop любой топ-игры: <i>купи → поставь → развивай</i>
|
|
||||||
(Pet Simulator, Lumber Tycoon, Build A Boat).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Shot src="guide-zavod-play.png" wide
|
|
||||||
caption="Завод: магазин снизу, тень-превью предмета летит за курсором, вокруг — воксельный мир с деревьями и прудом." />
|
|
||||||
|
|
||||||
<h3 className="lessonH">Чему научишься</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>game.inventoryUi.create(opts)</b> — готовый магазин-инвентарь:
|
|
||||||
нижняя панель слотов с иконкой/ценой; клик по слоту зовёт
|
|
||||||
<code> onSlotClick(item)</code>. Слот сам сереет, если валюты мало;</li>
|
|
||||||
<li><b>game.placement.start(itemKey, opts)</b> — войти в режим
|
|
||||||
расстановки: тень предмета за курсором, снап к сетке, проверка
|
|
||||||
зоны и баланса, подсветка валидности (зелёный/красный);</li>
|
|
||||||
<li><b>placement.onPlace(fn)</b> — колбэк «предмет поставлен»:
|
|
||||||
тут спавним реальный объект и списываем деньги;</li>
|
|
||||||
<li><b>тень-превью формой модели</b> — для воксельных моделей тень
|
|
||||||
повторяет их геометрию (не куб) и основание точно под курсором;</li>
|
|
||||||
<li><b>стопка и снап</b> — предметы ложатся друг на друга и
|
|
||||||
прилипают к сетке;</li>
|
|
||||||
<li><b>inventoryUi.setBalance(валюта, сумма)</b> — обновляешь баланс →
|
|
||||||
дорогие слоты автоматически серые (нельзя уйти в минус);</li>
|
|
||||||
<li><b>воксельные модели + ландшафт</b> — мир собран из voxel-
|
|
||||||
моделей и террейна (трава/деревья/вода), а не голых кубов.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 className="lessonH">Шаг 1. Магазин-инвентарь снизу</h3>
|
|
||||||
<p>
|
|
||||||
<code>game.inventoryUi.create</code> рисует панель слотов. Каждый
|
|
||||||
слот — это товар: ключ, название, иконка, цена и тип модели.
|
|
||||||
В <code>onSlotClick</code> запускаем расстановку этого товара.
|
|
||||||
</p>
|
|
||||||
<ScriptKind kind="global" />
|
|
||||||
<Code>{`let rubles = 1000;
|
|
||||||
function wallet() {
|
|
||||||
game.ui.set('wallet', game.format.money(rubles) + ' рубликов',
|
|
||||||
{ x: 50, y: 6, anchor: 'top', color: '#ffd23a', size: 24 });
|
|
||||||
// Обновляем баланс магазина — слоты дороже денег станут серыми:
|
|
||||||
game.inventoryUi.setBalance('rubles', rubles);
|
|
||||||
}
|
|
||||||
wallet();
|
|
||||||
|
|
||||||
game.after(0.4, () => {
|
|
||||||
game.inventoryUi.create({
|
|
||||||
items: [
|
|
||||||
{ key: 'crate', name: 'Ящик', icon: 'crate', cost: 50, modelType: 'user:444' },
|
|
||||||
{ key: 'tree', name: 'Дерево', icon: 'plant', cost: 100, modelType: 'user:445' },
|
|
||||||
{ key: 'oven', name: 'Печь', icon: 'oven', cost: 500, modelType: 'user:446' },
|
|
||||||
],
|
|
||||||
position: 'bottom', showCost: true, showCurrency: 'rubles',
|
|
||||||
onSlotClick: (item) => startPlacing(item.key), // ← см. Шаг 2
|
|
||||||
});
|
|
||||||
wallet();
|
|
||||||
});`}</Code>
|
|
||||||
|
|
||||||
<h3 className="lessonH">Шаг 2. Режим расстановки (тень за курсором)</h3>
|
|
||||||
<p>
|
|
||||||
<code>game.placement.start</code> создаёт полупрозрачную тень и
|
|
||||||
включает режим: тень едет за курсором, снаппится к сетке, краснеет
|
|
||||||
вне зоны. <code>previewScale</code> для куба — размер в юнитах, а
|
|
||||||
для воксельной модели задавай <code>modelScale</code> = масштаб
|
|
||||||
модели — тогда тень будет точной формой предмета.
|
|
||||||
</p>
|
|
||||||
<ScriptKind kind="global" />
|
|
||||||
<Code>{`const SHOP = {
|
|
||||||
crate: { id: 'user:444', cost: 50, scale: 1.6 },
|
|
||||||
tree: { id: 'user:445', cost: 100, scale: 7.0 },
|
|
||||||
oven: { id: 'user:446', cost: 500, scale: 2.0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
function startPlacing(key) {
|
|
||||||
const s = SHOP[key];
|
|
||||||
game.placement.start(key, {
|
|
||||||
previewType: s.id, // 'user:<id>' — воксельная модель
|
|
||||||
modelScale: s.scale, // тень той же формы и размера, что предмет
|
|
||||||
surfaceMode: 'ground', // ставим на землю ИЛИ на верх другого объекта (стопка)
|
|
||||||
grid: 0.5, // снап к полусетке
|
|
||||||
cost: s.cost, currency: 'rubles', // нет денег → ставить нельзя
|
|
||||||
targetZone: game.scene.findOne('player-plot'), // свой участок
|
|
||||||
chainPlace: true, // ставить подряд, не выходя из режима
|
|
||||||
previewPulse: true, // тень слегка пульсирует
|
|
||||||
});
|
|
||||||
}`}</Code>
|
|
||||||
<Note>
|
|
||||||
Управление в режиме расстановки: <b>ЛКМ</b> — поставить,
|
|
||||||
<b> R</b> или <b>колесо мыши</b> — повернуть на 90°,
|
|
||||||
<b> ПКМ / Esc</b> — отменить. С <code>chainPlace: true</code>
|
|
||||||
после установки режим не закрывается — удобно строить рядами.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
<h3 className="lessonH">Шаг 3. Поставили — спавним и платим</h3>
|
|
||||||
<p>
|
|
||||||
Когда игрок жмёт ЛКМ на валидном месте, движок зовёт
|
|
||||||
<code> onPlace</code> с позицией и поворотом. Здесь создаём
|
|
||||||
<b> настоящий</b> объект (тень — лишь подсказка) и списываем монеты.
|
|
||||||
</p>
|
|
||||||
<ScriptKind kind="global" />
|
|
||||||
<Code>{`game.placement.onPlace(({ itemKey, position, rotationY }) => {
|
|
||||||
const s = SHOP[itemKey];
|
|
||||||
game.scene.spawn(s.id, {
|
|
||||||
x: position.x, y: position.y, z: position.z,
|
|
||||||
rotationY: rotationY, scale: s.scale,
|
|
||||||
name: itemKey + '_' + Date.now(),
|
|
||||||
});
|
|
||||||
rubles -= s.cost;
|
|
||||||
wallet(); // обновили счётчик и серость слотов
|
|
||||||
});
|
|
||||||
|
|
||||||
// (необязательно) реакция на отмену:
|
|
||||||
game.placement.onCancel(() => game.ui.set('hint', '', {}));`}</Code>
|
|
||||||
|
|
||||||
<Shot src="guide-zavod-scene.png" wide
|
|
||||||
caption="Сцена в редакторе: воксельный остров с травой, деревьями и прудом; в центре — участок завода, где расставляются предметы." />
|
|
||||||
|
|
||||||
<h3 className="lessonH">Почему это «честная» механика</h3>
|
|
||||||
<p>
|
|
||||||
<b>Тень ≠ предмет.</b> Пока ты в режиме расстановки — на сцене
|
|
||||||
только полупрозрачная подсказка. Реальный объект появляется
|
|
||||||
<i> один раз</i> в <code>onPlace</code>. Поэтому ничего не дублируется,
|
|
||||||
а всё, что наспавнено за игру, <b>удаляется при выходе (Esc/Стоп)</b> —
|
|
||||||
игровая сессия не «протекает» в сохранение. Деньги списываются только
|
|
||||||
при успешной установке, а недоступные по цене слоты серые — <b>в минус
|
|
||||||
уйти нельзя</b>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Try>
|
|
||||||
Добавь четвёртый товар в магазин (например, фонарь — модель
|
|
||||||
<code> user:448</code>) со своей ценой. Сделай ему дорогую стоимость
|
|
||||||
и проверь: пока денег мало — слот серый и не ставится, а как накопишь —
|
|
||||||
станет активным. Попробуй построить из ящиков башню (стопкой).
|
|
||||||
</Try>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Есть ли готовый текст урока для игры с таким id. */
|
/** Есть ли готовый текст урока для игры с таким id. */
|
||||||
|
|||||||
@ -16,12 +16,6 @@ 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++) {
|
||||||
|
|||||||
@ -549,12 +549,6 @@ const KubikonEditor = () => {
|
|||||||
// не отработал, или setSceneLoading(false) попал в обход (race).
|
// не отработал, или setSceneLoading(false) попал в обход (race).
|
||||||
const safetyTimer = setTimeout(() => {
|
const safetyTimer = setTimeout(() => {
|
||||||
console.warn('[KubikonEditor] safety timer: forcing setSceneLoading(false) after 60s');
|
console.warn('[KubikonEditor] safety timer: forcing setSceneLoading(false) after 60s');
|
||||||
// Загрузка не завершилась штатно за 60с (медленная сеть / таймаут
|
|
||||||
// getProject / частично загруженные модели) → помечаем как СБОЙ
|
|
||||||
// загрузки, чтобы автосейв НЕ затёр проект частичной/пустой сценой.
|
|
||||||
// Без этого terrain мог загрузиться частично (напр. 3 из 13173) и
|
|
||||||
// автосейв писал эту пустышку в БД (инцидент 2026-06-02).
|
|
||||||
loadFailedRef.current = true;
|
|
||||||
setSceneLoading(false);
|
setSceneLoading(false);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
return () => {
|
return () => {
|
||||||
@ -580,12 +574,6 @@ const KubikonEditor = () => {
|
|||||||
* 0 — защита неактивна.
|
* 0 — защита неактивна.
|
||||||
*/
|
*/
|
||||||
const lastLoadedDecoCountRef = useRef(0);
|
const lastLoadedDecoCountRef = useRef(0);
|
||||||
// Guard от потери воксельных моделей и скриптов при частичной загрузке:
|
|
||||||
// terrain мог догрузиться, а userModels/scripts — нет (getProject упал по
|
|
||||||
// таймауту на середине), и автосейв затирал их. Запоминаем сколько было
|
|
||||||
// ЗАГРУЖЕНО — если стало 0 при ненулевом базовом, блокируем сейв.
|
|
||||||
const lastLoadedUserModelCountRef = useRef(0);
|
|
||||||
const lastLoadedScriptCountRef = useRef(0);
|
|
||||||
// HP игрока + патроны
|
// HP игрока + патроны
|
||||||
const [playerHp, setPlayerHp] = useState({ hp: 100, maxHp: 100 });
|
const [playerHp, setPlayerHp] = useState({ hp: 100, maxHp: 100 });
|
||||||
// Скрипт через game.hud.setVisible(false) может полностью скрыть HUD
|
// Скрипт через game.hud.setVisible(false) может полностью скрыть HUD
|
||||||
@ -1026,33 +1014,6 @@ const KubikonEditor = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === ЗАЩИТА ОТ WIPE: воксельные модели и скрипты ===
|
|
||||||
// terrain мог догрузиться, а userModels/scripts — нет (частичная загрузка
|
|
||||||
// из-за таймаута getProject), и автосейв затирал их пустотой. Если было
|
|
||||||
// загружено N (>0), а сейчас 0 И пользователь не редактировал — блок.
|
|
||||||
// Реальный инцидент 2026-06-02: игра «Мой завод» (2345) потеряла все
|
|
||||||
// 8 моделей и скрипт магазина после частичной загрузки.
|
|
||||||
if (!userWasEditing) {
|
|
||||||
const s2 = sceneRef.current;
|
|
||||||
const curUm = s2.userModelManager?.instances?.size ?? 0;
|
|
||||||
const curScr = (s2._scripts || []).filter(x => x && x.id !== 'demo').length;
|
|
||||||
const lostUm = lastLoadedUserModelCountRef.current > 0 && curUm === 0;
|
|
||||||
const lostScr = lastLoadedScriptCountRef.current > 0 && curScr === 0;
|
|
||||||
if (lostUm || lostScr) {
|
|
||||||
console.error(
|
|
||||||
`[KubikonEditor] SAVE BLOCKED: потеря userModels(${lastLoadedUserModelCountRef.current}→${curUm}) `
|
|
||||||
+ `или scripts(${lastLoadedScriptCountRef.current}→${curScr}) — вероятно частичная загрузка. Перезагрузите страницу.`
|
|
||||||
);
|
|
||||||
setSaveStatus('error');
|
|
||||||
setSaveDetail({
|
|
||||||
phase: 'Сохранение заблокировано: пропали модели/скрипты (неполная загрузка). Перезагрузите страницу!',
|
|
||||||
pct: 0, error: true,
|
|
||||||
});
|
|
||||||
setTimeout(() => setSaveDetail(null), 8000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
setSaveDetail({ phase: 'Сбор данных сцены…', pct: 5 });
|
setSaveDetail({ phase: 'Сбор данных сцены…', pct: 5 });
|
||||||
@ -1457,13 +1418,7 @@ const KubikonEditor = () => {
|
|||||||
// автосейв затирал деко (реальный инцидент с проектом 222).
|
// автосейв затирал деко (реальный инцидент с проектом 222).
|
||||||
const deco = sceneRef.current._smoothDecoManager?.getStats?.().total ?? 0;
|
const deco = sceneRef.current._smoothDecoManager?.getStats?.().total ?? 0;
|
||||||
lastLoadedDecoCountRef.current = deco;
|
lastLoadedDecoCountRef.current = deco;
|
||||||
// Guard для воксельных моделей и скриптов (см. ref выше).
|
console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}`);
|
||||||
const umLoaded = sceneRef.current.userModelManager?.instances?.size ?? 0;
|
|
||||||
const scrLoaded = (sceneRef.current._scripts || [])
|
|
||||||
.filter(x => x && x.id !== 'demo').length;
|
|
||||||
lastLoadedUserModelCountRef.current = umLoaded;
|
|
||||||
lastLoadedScriptCountRef.current = scrLoaded;
|
|
||||||
console.log(`[KubikonEditor] guard armed: lastLoadedVoxelCount=${tm + tmesh + rt} (legacy=${tm}, tmesh=${tmesh}, roblox=${rt}), deco=${deco}, userModels=${umLoaded}, scripts=${scrLoaded}`);
|
|
||||||
// Триггерим пересчёт hasRobloxTerrain в TerrainGenPanel
|
// Триггерим пересчёт hasRobloxTerrain в TerrainGenPanel
|
||||||
if (rt > 0) setRobloxTerrainBump((n) => n + 1);
|
if (rt > 0) setRobloxTerrainBump((n) => n + 1);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|||||||
@ -56,11 +56,7 @@ export default function MinimapOverlay({ scene }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
// 2026-06-02: миникарту показываем ТОЛЬКО если игра её явно включила
|
const v = !!scene._terrainStreamingEnabled;
|
||||||
// (scene._minimapEnabled, флаг из настроек сцены). Раньше она
|
|
||||||
// появлялась автоматически на любой большой карте (streaming) и
|
|
||||||
// мешала — лишний оверлей в играх, где карта не нужна.
|
|
||||||
const v = !!scene._minimapEnabled;
|
|
||||||
setVisible(prev => prev !== v ? v : prev);
|
setVisible(prev => prev !== v ? v : prev);
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
|||||||
@ -38,8 +38,6 @@ import {
|
|||||||
PointerEventTypes,
|
PointerEventTypes,
|
||||||
Tools as BabylonTools,
|
Tools as BabylonTools,
|
||||||
} from '@babylonjs/core';
|
} from '@babylonjs/core';
|
||||||
import { PlacementManager } from './PlacementManager';
|
|
||||||
import { ShopInventoryUi } from './ShopInventoryUi';
|
|
||||||
import { BlockManager } from './BlockManager';
|
import { BlockManager } from './BlockManager';
|
||||||
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
|
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
|
||||||
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
|
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
|
||||||
@ -147,14 +145,6 @@ 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 — drag-and-drop размещение объектов (задача 11).
|
|
||||||
// Менеджеры создаются лениво в GameRuntime при первом game.placement.start /
|
|
||||||
// game.inventoryUi.create. Классы передаём ссылкой, чтобы GameRuntime не
|
|
||||||
// импортировал их напрямую (избегаем циклических импортов).
|
|
||||||
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;
|
||||||
@ -2208,12 +2198,6 @@ export class BabylonScene {
|
|||||||
// даже если поверх есть другие listeners.
|
// даже если поверх есть другие listeners.
|
||||||
const onMouseDown = (e) => {
|
const onMouseDown = (e) => {
|
||||||
if (this._isPlaying) {
|
if (this._isPlaying) {
|
||||||
// Placement mode (задача 11): ЛКМ ставит, ПКМ отменяет.
|
|
||||||
// Перехватываем ДО обычного play-клика, пока режим активен.
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
|
||||||
// Pointer Lock — курсор всё равно в центре экрана.
|
// Pointer Lock — курсор всё равно в центре экрана.
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
@ -2414,11 +2398,6 @@ export class BabylonScene {
|
|||||||
|
|
||||||
const onWheel = (e) => {
|
const onWheel = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Placement mode (задача 11): колесо вверх — повернуть preview.
|
|
||||||
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));
|
||||||
@ -2466,11 +2445,6 @@ 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 });
|
||||||
}
|
}
|
||||||
// Placement mode (задача 11): R — повернуть preview, Esc — отмена.
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
@ -7574,10 +7548,6 @@ export class BabylonScene {
|
|||||||
this.gameRuntime = null;
|
this.gameRuntime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placement mode (задача 11): сброс активной сессии + виджета магазина.
|
|
||||||
if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) { /* ignore */ } this.placementManager = null; }
|
|
||||||
if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) { /* ignore */ } this.shopInventoryUi = null; }
|
|
||||||
|
|
||||||
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
|
||||||
if (this.gdLevelManager) {
|
if (this.gdLevelManager) {
|
||||||
this.gdLevelManager.stop();
|
this.gdLevelManager.stop();
|
||||||
|
|||||||
@ -505,12 +505,6 @@ 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 и т.п.).
|
|
||||||
// БЕЗ этой ветки placed-объекты (ящик/дерево/печь) оставались
|
|
||||||
// на сцене после Stop — игровая механика «утекала» в редактор
|
|
||||||
// (баг задачи 11, 2026-06-02).
|
|
||||||
s?.userModelManager?.removeInstance(Number(rest));
|
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
|
} catch (e) { /* ignore — объект мог быть уже удалён скриптом */ }
|
||||||
}
|
}
|
||||||
@ -1215,31 +1209,6 @@ 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 {
|
|
||||||
// Динамический импорт не нужен — модуль подключён в BabylonScene.
|
|
||||||
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';
|
||||||
@ -1718,55 +1687,6 @@ export class GameRuntime {
|
|||||||
if (cid != null) this.scene3d?.constraintManager?.remove(cid);
|
if (cid != null) this.scene3d?.constraintManager?.remove(cid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// === Placement mode — drag-and-drop размещение (задача 11) ===
|
|
||||||
if (cmd === 'placement.start') {
|
|
||||||
const pm = this._ensurePlacementManager();
|
|
||||||
if (pm && payload) {
|
|
||||||
// Колбэки placement → рассылаем в worker как globalEvent.
|
|
||||||
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; }
|
|
||||||
|
|
||||||
// === Слот-инвентарь магазина (задача 11) ===
|
|
||||||
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') {
|
|
||||||
// Баланс идёт И в виджет (серые слоты), И в PlacementManager
|
|
||||||
// (чтобы нельзя было подтвердить покупку в минус).
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
||||||
if (cmd === 'fx.create') {
|
if (cmd === 'fx.create') {
|
||||||
// payload: { kind: 'beam'|'trail', localRef, ... }
|
// payload: { kind: 'beam'|'trail', localRef, ... }
|
||||||
@ -3681,8 +3601,6 @@ 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,
|
||||||
// scale — воксельные модели мелкие, placement передаёт крупнее.
|
|
||||||
(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;
|
||||||
|
|||||||
@ -1,589 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 } from '@babylonjs/core/Meshes/meshBuilder';
|
|
||||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
|
||||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
|
||||||
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -97,12 +97,6 @@ let _selfTouchHandlers = [];
|
|||||||
let _selfUntouchHandlers = [];
|
let _selfUntouchHandlers = [];
|
||||||
// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt)
|
// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt)
|
||||||
let _selfInteractHandlers = [];
|
let _selfInteractHandlers = [];
|
||||||
// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove.
|
|
||||||
let _placeOnPlaceHandlers = [];
|
|
||||||
let _placeOnCancelHandlers = [];
|
|
||||||
let _placeOnMoveHandlers = [];
|
|
||||||
// Подписки слот-инвентаря магазина: game.inventoryUi onSlotClick.
|
|
||||||
let _invUiSlotClickHandlers = [];
|
|
||||||
// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (через findOne(x).onTouch).
|
// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (через findOne(x).onTouch).
|
||||||
// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих
|
// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих
|
||||||
// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick.
|
// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick.
|
||||||
@ -1571,9 +1565,6 @@ const game = {
|
|||||||
subType: 'user:' + subType,
|
subType: 'user:' + subType,
|
||||||
x, y, z,
|
x, y, z,
|
||||||
rotationY: opts.rotationY,
|
rotationY: opts.rotationY,
|
||||||
// scale — воксельная модель мелкая (VOXEL_SIZE 0.0625);
|
|
||||||
// для placement обычно нужен крупнее. Прокидываем в addInstance.
|
|
||||||
scale: opts.scale,
|
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
ref,
|
ref,
|
||||||
});
|
});
|
||||||
@ -3676,87 +3667,6 @@ 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', {}); },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -4149,20 +4059,6 @@ 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') {
|
|
||||||
// Задача 11: объект размещён. payload: { itemKey, position, rotationY }
|
|
||||||
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') {
|
|
||||||
// payload: { position, valid } — каждый кадр placement.
|
|
||||||
const ev = { position: payload.position, valid: !!payload.valid };
|
|
||||||
for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove');
|
|
||||||
} else if (t === 'invUiSlotClick') {
|
|
||||||
// payload: { key, item } — клик по слоту магазина.
|
|
||||||
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? }
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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; }
|
|
||||||
}
|
|
||||||
@ -506,14 +506,6 @@ 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: воксели «просвечивали» — видна была задняя грань сквозь
|
|
||||||
// переднюю. Две страховки против этого:
|
|
||||||
// 1) backFaceCulling=false — даже при инвертированном winding обе
|
|
||||||
// стороны грани рисуются, ближняя перекрывает дальнюю по depth.
|
|
||||||
// 2) hasAlpha=false ниже (RGBA-текстура не должна включать alpha-blend).
|
|
||||||
// Для прозрачных материалов (water/glacier с def.alpha<1) culling
|
|
||||||
// вернём true, чтобы blend выглядел корректно.
|
|
||||||
mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
|
|
||||||
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
// Ambient ставим в белый, чтобы hemisphere-light освещал материал
|
||||||
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
// с любой стороны (иначе нижние/тыловые грани выходят серыми, что
|
||||||
// особенно заметно на светло-бежевом песке — он становится серым).
|
// особенно заметно на светло-бежевом песке — он становится серым).
|
||||||
@ -537,17 +529,6 @@ 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 {
|
|
||||||
// ВАЖНО (2026-06-02): наши PNG-текстуры в формате RGBA (с альфа-
|
|
||||||
// каналом, даже если он весь 255 = непрозрачный). Babylon, видя
|
|
||||||
// альфа-канал, может включить alpha-blending → грани рисуются
|
|
||||||
// без записи в depth-buffer → дальние воксели «просвечивают»
|
|
||||||
// сквозь ближние (листва/трава были полупрозрачными). Явно
|
|
||||||
// выключаем альфу для непрозрачных материалов — крона/трава
|
|
||||||
// становятся плотными.
|
|
||||||
mat.diffuseTexture.hasAlpha = false;
|
|
||||||
mat.useAlphaFromDiffuseTexture = false;
|
|
||||||
mat.transparencyMode = 0; // OPAQUE
|
|
||||||
}
|
}
|
||||||
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]);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user