diff --git a/src/editor/BillboardEditorModal.jsx b/src/editor/BillboardEditorModal.jsx
new file mode 100644
index 0000000..1a084c0
--- /dev/null
+++ b/src/editor/BillboardEditorModal.jsx
@@ -0,0 +1,466 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import cl from './GameSettingsModal.module.css';
+import Icon from './Icon';
+import { BillboardUiManager } from './engine/BillboardUiManager';
+
+/**
+ * BillboardEditorModal — редактор контента 3D-таблички (billboard primitive).
+ *
+ * Открывается из InspectorPanel при клике «Редактировать табличку…».
+ * Слева — форма (пресет + контент-поля), справа — живое превью на canvas
+ * (тот же рендер что в BillboardUiManager._render).
+ *
+ * Пресеты:
+ * shop-item — иконка | заголовок + sub | кнопка цены
+ * shop-purchase — иконка | название | sub | кнопка
+ * banner — крупный текст по центру
+ * sign — простой указатель
+ *
+ * Props:
+ * open — boolean
+ * primitiveData — { id, billboard: {template, face, content, elements} }
+ * onClose
+ * onApply(opts) — { template, face, content }
+ */
+
+const TEMPLATES = BillboardUiManager.getAvailableTemplates();
+const ICONS = BillboardUiManager.getAvailableIcons();
+
+// 8 готовых цветовых пресетов градиента — чтобы дети не подбирали HEX'ы.
+const GRADIENT_PRESETS = [
+ { name: 'Красный', from: '#ff5a5a', to: '#ff8a3d' },
+ { name: 'Синий', from: '#3b82f6', to: '#0ea5e9' },
+ { name: 'Зелёный', from: '#10b981', to: '#34d399' },
+ { name: 'Фиолетовый', from: '#7c3aed', to: '#a855f7' },
+ { name: 'Жёлтый', from: '#f59e0b', to: '#fbbf24' },
+ { name: 'Розовый', from: '#ec4899', to: '#f472b6' },
+ { name: 'Бирюзовый', from: '#06b6d4', to: '#22d3ee' },
+ { name: 'Тёмный', from: '#1f2937', to: '#374151' },
+];
+
+const BillboardEditorModal = ({ open, primitiveData, onClose, onApply }) => {
+ const initial = primitiveData?.billboard || {};
+ const [template, setTemplate] = useState(initial.template || 'shop-item');
+ const [face, setFace] = useState(initial.face || 'camera');
+ const initContent = initial.content || {};
+ const [icon, setIcon] = useState(initContent.icon || 'hammer');
+ const [title, setTitle] = useState(initContent.title || 'Апгрейд');
+ const [sub, setSub] = useState(initContent.sub || '1 > 2');
+ const [price, setPrice] = useState(initContent.price || '$10,000');
+ const [gradFrom, setGradFrom] = useState(
+ (initContent.gradient && initContent.gradient[0]) || '#ff5a5a');
+ const [gradTo, setGradTo] = useState(
+ (initContent.gradient && initContent.gradient[1]) || '#ff8a3d');
+
+ // Перезагружаем поля при открытии новой таблички
+ useEffect(() => {
+ if (!open) return;
+ const init = primitiveData?.billboard || {};
+ const c = init.content || {};
+ setTemplate(init.template || 'shop-item');
+ setFace(init.face || 'camera');
+ setIcon(c.icon || 'hammer');
+ setTitle(c.title || (init.template === 'banner' ? 'Заголовок баннера' : 'Апгрейд'));
+ setSub(c.sub || '1 > 2');
+ setPrice(c.price || '$10,000');
+ if (c.gradient) {
+ setGradFrom(c.gradient[0] || '#ff5a5a');
+ setGradTo(c.gradient[1] || '#ff8a3d');
+ }
+ }, [open, primitiveData]);
+
+ const templateDef = useMemo(
+ () => TEMPLATES.find(t => t.id === template) || TEMPLATES[0],
+ [template]);
+
+ // Поля доступные для текущего шаблона
+ const fields = templateDef.fields;
+
+ // === Живое превью на canvas ===
+ const canvasRef = useRef(null);
+ const previewMgrRef = useRef(null);
+ useEffect(() => {
+ if (!open || !canvasRef.current) return;
+ // Создаём отдельный canvas-based рендер. Мы НЕ используем сам BillboardUiManager
+ // (он требует Babylon scene). Дублируем minimal-логику рисования здесь,
+ // вызывая те же приватные методы рендера через статический хелпер.
+ // Проще всего — сделать temp-инстанс с фиктивной scene и вызвать _render.
+ // Но _render требует dyn.getContext()/.update() — Babylon API. Поэтому
+ // рисуем напрямую на 2D canvas с упрощённой копией логики ниже.
+ renderPreview(canvasRef.current, template, {
+ icon, title, sub, price,
+ gradient: [gradFrom, gradTo],
+ });
+ }, [open, template, icon, title, sub, price, gradFrom, gradTo]);
+
+ if (!open) return null;
+
+ const handleApply = () => {
+ const content = {};
+ if (fields.includes('icon')) content.icon = icon;
+ if (fields.includes('title')) content.title = title;
+ if (fields.includes('sub')) content.sub = sub;
+ if (fields.includes('price')) content.price = price;
+ if (fields.includes('gradient')) content.gradient = [gradFrom, gradTo];
+ onApply({ template, face, content });
+ };
+
+ return (
+
+
e.stopPropagation()}
+ style={{ maxWidth: 920, width: '90vw' }}>
+
+
+
+ Редактор 3D-таблички
+
+
+
+
+
+ {/* Левая колонка — форма */}
+
+
+
+ {TEMPLATES.map(t => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {fields.includes('icon') && (
+
+
+
+ )}
+
+ {fields.includes('title') && (
+
+ )}
+
+ {fields.includes('sub') && (
+
+ )}
+
+ {fields.includes('price') && (
+
+ )}
+
+ {fields.includes('gradient') && (
+
+ )}
+
+
+ {/* Правая колонка — превью */}
+
+
Превью:
+
+
+
+
+ {templateDef.hasButton && 'В Play кнопка кликабельна — подпишись через game.billboard.onClick(ref, "buy", fn).'}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// ─── Утилиты UI ─────────────────────────────────────────────────────────────
+
+function Section({ title, children }) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+const inputStyle = {
+ width: '100%',
+ padding: '6px 10px',
+ background: '#1f2937',
+ border: '1px solid #374151',
+ borderRadius: 4,
+ color: '#e5e7eb',
+ fontSize: 13,
+ boxSizing: 'border-box',
+};
+
+function pillStyle(active) {
+ return {
+ padding: '6px 12px',
+ background: active ? '#3357ff' : '#1f2937',
+ border: '1px solid ' + (active ? '#3357ff' : '#374151'),
+ borderRadius: 4,
+ color: active ? '#fff' : '#9ca3af',
+ fontSize: 12,
+ cursor: 'pointer',
+ flex: 1,
+ whiteSpace: 'nowrap',
+ };
+}
+
+// ─── Рендер превью (зеркалит BillboardUiManager._render*) ──────────────────
+
+const PREVIEW_ICONS = {
+ hammer: '🔨', saw: '🪚', drop: '💧', seed: '🌱', cube: '🧊',
+ coin: '💰', home: '🏠', rocket: '🚀', sprinkler: '⛲', sparkle: '✨',
+ star: '⭐', bag: '🎒', diamond: '💎', fire: '🔥', lightning: '⚡',
+ heart: '❤', key: '🔑', shield: '🛡',
+};
+
+const W = 512, H = 320;
+const BTN = { x: 332, y: 200, w: 160, h: 90 };
+
+function renderPreview(canvas, template, content) {
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, W, H);
+ switch (template) {
+ case 'shop-item': drawShopItem(ctx, content); break;
+ case 'shop-purchase': drawShopPurchase(ctx, content); break;
+ case 'banner': drawBanner(ctx, content); break;
+ case 'sign': drawSign(ctx, content); break;
+ default: drawBanner(ctx, content);
+ }
+}
+
+function roundedGrad(ctx, x, y, w, h, opts) {
+ const r = opts.radius ?? 24;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ const grad = ctx.createLinearGradient(x, y, x, y + h);
+ const [from, to] = opts.gradient || ['#333', '#111'];
+ grad.addColorStop(0, from);
+ grad.addColorStop(1, to);
+ ctx.fillStyle = grad;
+ ctx.fill();
+ if (opts.stroke) {
+ ctx.lineWidth = opts.stroke.width ?? 3;
+ ctx.strokeStyle = opts.stroke.color || '#000';
+ ctx.stroke();
+ }
+}
+
+function drawShopItem(ctx, c) {
+ roundedGrad(ctx, 8, 8, W - 16, H - 16, {
+ gradient: c.gradient || ['#ff5a5a', '#ff8a3d'],
+ radius: 28, stroke: { color: '#0008', width: 4 },
+ });
+ ctx.beginPath();
+ ctx.arc(110, 130, 70, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
+ ctx.fill();
+ ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(PREVIEW_ICONS[c.icon] || '🧊', 110, 132);
+ ctx.font = 'bold 36px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText((c.title || '').slice(0, 18), 200, 50);
+ if (c.sub) {
+ ctx.font = 'bold 28px Arial, sans-serif';
+ ctx.fillStyle = '#a7f3d0';
+ ctx.fillText(c.sub, 200, 105);
+ }
+ roundedGrad(ctx, BTN.x, BTN.y, BTN.w, BTN.h, {
+ gradient: ['#fbbf24', '#f59e0b'], radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = 'bold 36px Arial, sans-serif';
+ ctx.fillStyle = '#1c1917';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(c.price || '$0', BTN.x + BTN.w / 2, BTN.y + BTN.h / 2);
+}
+
+function drawShopPurchase(ctx, c) {
+ roundedGrad(ctx, 8, 8, W - 16, H - 16, {
+ gradient: c.gradient || ['#3b82f6', '#0ea5e9'],
+ radius: 28, stroke: { color: '#0008', width: 4 },
+ });
+ ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(PREVIEW_ICONS[c.icon] || '🎒', 110, 140);
+ ctx.font = 'bold 34px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText((c.title || '').slice(0, 16), 200, 50);
+ if (c.sub) {
+ ctx.font = 'bold 26px Arial, sans-serif';
+ ctx.fillStyle = '#dbeafe';
+ ctx.fillText(c.sub, 200, 100);
+ }
+ roundedGrad(ctx, BTN.x, BTN.y, BTN.w, BTN.h, {
+ gradient: ['#a855f7', '#7c3aed'], radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = 'bold 34px Arial, sans-serif';
+ ctx.fillStyle = '#fff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(c.price || '0 R', BTN.x + BTN.w / 2, BTN.y + BTN.h / 2);
+}
+
+function drawBanner(ctx, c) {
+ roundedGrad(ctx, 8, 8, W - 16, H - 16, {
+ gradient: c.gradient || ['#7c3aed', '#a855f7'],
+ radius: 28, stroke: { color: '#0008', width: 4 },
+ });
+ ctx.font = 'bold 46px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
+ ctx.shadowBlur = 8;
+ ctx.shadowOffsetY = 3;
+ const lines = wrapText(ctx, c.title || '', W - 80);
+ const lh = 56;
+ const sy = H / 2 - (lines.length - 1) * lh / 2;
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], W / 2, sy + i * lh);
+ }
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetY = 0;
+}
+
+function drawSign(ctx, c) {
+ roundedGrad(ctx, 8, 8, W - 16, H - 16, {
+ gradient: c.gradient || ['#1f2937', '#374151'],
+ radius: 20, stroke: { color: '#fff', width: 4 },
+ });
+ ctx.font = 'bold 64px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText((c.title || '').slice(0, 14), W / 2, H / 2);
+}
+
+function wrapText(ctx, text, maxW) {
+ const words = (text || '').split(' ');
+ const lines = [];
+ let cur = '';
+ for (const w of words) {
+ const t = cur ? cur + ' ' + w : w;
+ if (ctx.measureText(t).width <= maxW) cur = t;
+ else { if (cur) lines.push(cur); cur = w; }
+ }
+ if (cur) lines.push(cur);
+ return lines.slice(0, 3);
+}
+
+export default BillboardEditorModal;
diff --git a/src/editor/Icon.jsx b/src/editor/Icon.jsx
index 431f68a..09072df 100644
--- a/src/editor/Icon.jsx
+++ b/src/editor/Icon.jsx
@@ -255,6 +255,8 @@ const GLYPHS = {
'prim-checkpoint': () => (<>>),
'prim-light': () => (<>>),
'prim-emitter': () => (<>>),
+ // Табличка с GUI: прямоугольник с заголовком, иконкой-кружком и кнопкой
+ 'prim-billboard': () => (<>>),
'prim-portal': () => (<>>),
'prim-spike': () => (<>>),
'prim-finish': () => (<>>),
diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx
index a5a517b..594b9e3 100644
--- a/src/editor/InspectorPanel.jsx
+++ b/src/editor/InspectorPanel.jsx
@@ -1666,6 +1666,24 @@ const InspectorPanel = ({
)}
+ {/* 3D-табличка (billboard) — кнопка «Редактировать табличку» */}
+ {primitiveType?.kind === 'billboard' && (
+
+
Контент таблички
+
+
+ {(selection.template || 'shop-item')} · {(selection.face || 'camera') === 'camera' ? 'смотрит на камеру' : 'фиксирована'}
+
+
+ )}
+
{/* Свойства — для всех примитивов */}
Свойства
diff --git a/src/editor/KubikonEditor.jsx b/src/editor/KubikonEditor.jsx
index e296a72..3c204da 100644
--- a/src/editor/KubikonEditor.jsx
+++ b/src/editor/KubikonEditor.jsx
@@ -21,6 +21,7 @@ import TopRibbon from './TopRibbon';
import TerrainPanel from './TerrainPanel';
import ModelEditorScreen from './ModelEditorScreen';
import ModelSaveModal from './ModelSaveModal';
+import BillboardEditorModal from './BillboardEditorModal';
import TerrainGenPanel from './TerrainGenPanel';
import ScriptConsole from './ScriptConsole';
import SceneTabs from './SceneTabs';
@@ -379,6 +380,9 @@ const KubikonEditor = () => {
const [editingUserModelId, setEditingUserModelId] = useState(null);
// Состояние для модалки "Настройки модели" (имя/описание/публичность)
const [settingsModalModel, setSettingsModalModel] = useState(null);
+ // BillboardEditorModal — открывается из инспектора при клике
+ // «Редактировать табличку…». Содержит primitiveData выделенного билборда.
+ const [billboardEditorData, setBillboardEditorData] = useState(null);
// Bumper для обновления списков в Toolbox после edit/settings/delete.
const [userModelsRefreshKey, setUserModelsRefreshKey] = useState(0);
// Bump-счётчик: инкрементируется при создании/очистке гладкого
@@ -2921,6 +2925,23 @@ const KubikonEditor = () => {
sceneRef.current?.resizeSelectedPrimitiveTo(sx, sy, sz)}
onSetPrimitiveProps={(patch) =>
sceneRef.current?.setSelectedPrimitivePropsTo(patch)}
+ onEditBillboard={() => {
+ // Открываем модалку с данными выделенного billboard-примитива
+ const s = sceneRef.current;
+ const sel = s?.selection?._selection;
+ if (!sel || sel.type !== 'primitive') return;
+ const data = s?.primitiveManager?.instances?.get(sel.id);
+ if (!data || data.type !== 'billboard') return;
+ setBillboardEditorData({
+ id: data.id,
+ billboard: data.billboard ? {
+ template: data.billboard.template,
+ face: data.billboard.face,
+ content: { ...data.billboard.content },
+ elements: data.billboard.elements,
+ } : null,
+ });
+ }}
onSetAnchored={(val) =>
sceneRef.current?.setSelectedAnchored(val)}
onSetMass={(val) =>
@@ -3445,6 +3466,21 @@ const KubikonEditor = () => {
projectId={id === 'new' ? null : Number(id)}
hidden
/>
+ {/* Редактор контента 3D-таблички (billboard primitive) */}
+
setBillboardEditorData(null)}
+ onApply={(opts) => {
+ // Применяем к выделенному билборду через setSelectedPrimitivePropsTo
+ // (там логика прокидывания в PrimitiveManager + persist).
+ sceneRef.current?.setSelectedPrimitivePropsTo({
+ billboardOpts: opts,
+ });
+ markDirty();
+ setBillboardEditorData(null);
+ }}
+ />
);
};
diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js
index 875f97d..d95ab0c 100644
--- a/src/editor/engine/BabylonScene.js
+++ b/src/editor/engine/BabylonScene.js
@@ -35,6 +35,7 @@ import {
ParticleSystem,
Texture,
Ray,
+ PointerEventTypes,
Tools as BabylonTools,
} from '@babylonjs/core';
import { BlockManager } from './BlockManager';
@@ -53,6 +54,7 @@ import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
import { ModelManager } from './ModelManager';
import { PrimitiveManager } from './PrimitiveManager';
+import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager';
@@ -1235,6 +1237,11 @@ export class BabylonScene {
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
// (createEmitterParticles живёт на обёртке).
this.primitiveManager.scene3d = this;
+ // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
+ // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
+ // (type='billboard') сразу применить текстуру с дефолтным пресетом.
+ this.billboardUiManager = new BillboardUiManager(this.scene);
+ this.primitiveManager.billboardUiManager = this.billboardUiManager;
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager();
this.inventory = new InventoryManager();
@@ -1267,6 +1274,41 @@ export class BabylonScene {
this.selection.setUserModelManager(this.userModelManager);
this.selection.setScene3D(this);
+ // === Обработка кликов по 3D-табличкам (billboard) в Play-режиме ===
+ // При клике луч из позиции курсора (либо из центра экрана, если игрок
+ // в pointer-lock) → ищем под ним меш типа billboard → переводим точку
+ // пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
+ // Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
+ this.scene.onPointerObservable.add((info) => {
+ if (info.type !== PointerEventTypes.POINTERDOWN) return;
+ if (info.event && info.event.button !== 0) return; // только ЛКМ
+ if (!this._isPlaying) return;
+ // Для pointer-lock (FPS-камера) — стреляем из центра экрана.
+ // Иначе — используем pickInfo от Babylon (он уже от курсора).
+ let pi = info.pickInfo;
+ const inLock = (document.pointerLockElement != null);
+ if (inLock) {
+ const cx = this.engine.getRenderWidth() / 2;
+ const cy = this.engine.getRenderHeight() / 2;
+ pi = this.scene.pick(cx, cy, (m) => {
+ return m.metadata?.isPrimitive
+ && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
+ });
+ }
+ if (!pi || !pi.hit || !pi.pickedMesh) return;
+ const meta = pi.pickedMesh.metadata;
+ if (!meta || !meta.isPrimitive) return;
+ const data = this.primitiveManager.instances.get(meta.primitiveId);
+ if (!data || data.type !== 'billboard') return;
+ // UV точка пересечения с мешем (Babylon знает, если есть UV-координаты).
+ const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
+ if (!uv) return;
+ const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
+ if (buttonId) {
+ this.billboardUiManager.fireClick(data, buttonId);
+ }
+ });
+
// GizmoController — управляет 3 типами гизмо (move/rotate/scale).
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены.
// Babylon автоматически активирует pointer-observable utility-сцены
diff --git a/src/editor/engine/BillboardUiManager.js b/src/editor/engine/BillboardUiManager.js
new file mode 100644
index 0000000..d735042
--- /dev/null
+++ b/src/editor/engine/BillboardUiManager.js
@@ -0,0 +1,533 @@
+/**
+ * BillboardUiManager — управление 3D-табличками с GUI (BillboardGui в Roblox).
+ *
+ * Каждая табличка — это plane-mesh с натянутой DynamicTexture, на которой
+ * с помощью обычного Canvas 2D API рисуется содержимое: градиентный фон,
+ * иконка, заголовок, подзаголовок, кнопка цены.
+ *
+ * Поддерживает 4 пресета (template):
+ * - 'shop-item' — иконка слева, заголовок, "1 > 2", кнопка цены справа
+ * - 'shop-purchase' — иконка + название + цена в рубликах
+ * - 'banner' — крупная плашка с одним текстом
+ * - 'sign' — простой указатель с текстом
+ *
+ * Режимы ориентации:
+ * - 'camera' — всегда смотрит на камеру (BillboardMode.BILLBOARDMODE_ALL)
+ * - 'fixed' — фиксированная ориентация (используется rotationY mesh-а)
+ *
+ * Клики:
+ * - на mesh-е ставим pickable=true
+ * - в _handlePick ловим точку пересечения, переводим в UV,
+ * ищем под этой точкой кнопку и эмитим событие
+ *
+ * State хранится в PrimitiveManager.instances[id].billboard:
+ * {
+ * template: 'shop-item',
+ * face: 'camera',
+ * content: { icon, title, sub, price, gradient: [from, to] }
+ * // или для custom-режима — elements: [...]
+ * onClickHandlers: { 'buy': fn } // только в Play-режиме
+ * }
+ */
+import {
+ DynamicTexture, StandardMaterial, Color3, Mesh, Texture,
+} from '@babylonjs/core';
+
+// Размер текстуры таблички (UV pixels). Чем больше — тем чётче, но больше VRAM.
+// 512×320 даёт нормальное качество на расстоянии 2-10 метров.
+const TEXTURE_W = 512;
+const TEXTURE_H = 320;
+
+// Координаты кнопки в shop-item пресете (для hit-теста кликов).
+// Совпадают с тем что рисуется в _renderShopItem (cx, cy, cw, ch).
+const SHOP_ITEM_BUTTON = { x: 332, y: 200, w: 160, h: 90 };
+
+// Описания доступных иконок (key → emoji-аналог для Canvas).
+// На фронте мы не имеем доступа к специализированным icon-fonts,
+// поэтому используем простые символы рисуемые крупно. Можно расширить
+// до полноценной библиотеки PNG-иконок позже.
+const ICONS = {
+ hammer: '🔨',
+ saw: '🪚',
+ drop: '💧',
+ seed: '🌱',
+ cube: '🧊',
+ coin: '💰',
+ home: '🏠',
+ rocket: '🚀',
+ sprinkler: '⛲',
+ sparkle: '✨',
+ star: '⭐',
+ bag: '🎒',
+ diamond: '💎',
+ fire: '🔥',
+ lightning: '⚡',
+ heart: '❤',
+ key: '🔑',
+ shield: '🛡',
+};
+
+export class BillboardUiManager {
+ constructor(scene) {
+ this.scene = scene;
+ // Колбэки от пользовательских скриптов: key=`${id}:${buttonId}` → fn
+ this._clickHandlers = new Map();
+ }
+
+ /**
+ * Применить к существующему mesh-у настройки билборда:
+ * — натянуть DynamicTexture, нарисовать контент, выставить billboardMode.
+ *
+ * data — объект из PrimitiveManager.instances[id], содержит уже mesh, sx, sy, sz.
+ * billboardOpts — { template, face, content, elements }
+ */
+ applyToMesh(data, billboardOpts = {}) {
+ const mesh = data.mesh;
+ if (!mesh) return;
+
+ const template = billboardOpts.template || 'shop-item';
+ const face = billboardOpts.face || 'camera';
+ const content = billboardOpts.content || this._defaultContent(template);
+
+ // Создаём DynamicTexture один раз и кешируем на mesh.
+ let dyn = mesh.metadata?._billboardTexture;
+ if (!dyn) {
+ dyn = new DynamicTexture(
+ `bb_tex_${data.id}`,
+ { width: TEXTURE_W, height: TEXTURE_H },
+ this.scene,
+ false /* generateMipMaps */
+ );
+ dyn.hasAlpha = true;
+ // Новый StandardMaterial с этой текстурой как diffuseTexture+emissiveTexture
+ // (emissive чтобы светилось и без освещения, как UI).
+ const mat = new StandardMaterial(`bb_mat_${data.id}`, this.scene);
+ mat.diffuseTexture = dyn;
+ mat.emissiveTexture = dyn;
+ mat.emissiveColor = new Color3(1, 1, 1);
+ mat.specularColor = new Color3(0, 0, 0);
+ mat.useAlphaFromDiffuseTexture = true;
+ mat.backFaceCulling = false;
+ mesh.material = mat;
+ if (!mesh.metadata) mesh.metadata = {};
+ mesh.metadata._billboardTexture = dyn;
+ mesh.metadata._billboardMaterial = mat;
+ }
+
+ // Ориентация на камеру (BillboardMode_ALL = и X, и Y, и Z).
+ if (face === 'camera') {
+ mesh.billboardMode = Mesh.BILLBOARDMODE_ALL;
+ } else {
+ mesh.billboardMode = Mesh.BILLBOARDMODE_NONE;
+ }
+
+ // Сохраняем state в data для сериализации и для hit-теста кликов.
+ data.billboard = {
+ template,
+ face,
+ content: { ...content },
+ elements: billboardOpts.elements || null,
+ };
+
+ this._render(dyn, template, content, billboardOpts.elements);
+ }
+
+ /**
+ * Обновить контент билборда (без пересоздания текстуры).
+ * patch — частичные изменения к content (например {sub: '2 > 3', price: '$20,000'}).
+ */
+ update(data, patch) {
+ if (!data.billboard) return;
+ data.billboard.content = { ...data.billboard.content, ...patch };
+ const dyn = data.mesh?.metadata?._billboardTexture;
+ if (dyn) {
+ this._render(dyn, data.billboard.template, data.billboard.content, data.billboard.elements);
+ }
+ }
+
+ /** Подписаться на клик по кнопке билборда (только в Play-режиме). */
+ onClick(data, buttonId, fn) {
+ const key = `${data.id}:${buttonId}`;
+ this._clickHandlers.set(key, fn);
+ }
+
+ /** Снять все подписки (вызывается при остановке Play). */
+ clearHandlers() {
+ this._clickHandlers.clear();
+ }
+
+ /**
+ * Проверить клик по точке UV: вернуть buttonId или null.
+ * UV точка — нормализованная (0..1).
+ */
+ pickButtonAt(data, uvX, uvY) {
+ if (!data.billboard) return null;
+ const px = uvX * TEXTURE_W;
+ const py = (1 - uvY) * TEXTURE_H; // UV.y перевёрнута относительно canvas
+ // Кастомные elements имеют приоритет (если заданы)
+ if (data.billboard.elements) {
+ return this._hitTestElements(data.billboard.elements, px, py);
+ }
+ const tmpl = data.billboard.template;
+ if (tmpl === 'shop-item' || tmpl === 'shop-purchase') {
+ const b = SHOP_ITEM_BUTTON;
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
+ return 'buy';
+ }
+ }
+ // banner и sign — кнопок нет, только текст.
+ return null;
+ }
+
+ /** Вызвать обработчик клика, если он подписан. */
+ fireClick(data, buttonId) {
+ const key = `${data.id}:${buttonId}`;
+ const fn = this._clickHandlers.get(key);
+ if (fn) {
+ try { fn(); } catch (e) { console.error('[Billboard onClick]', e); }
+ }
+ // Также пишем кнопку в "нажатом" виде на 100мс для UX-фидбека.
+ this._flashButton(data, buttonId);
+ }
+
+ _flashButton(data, buttonId) {
+ if (!data.billboard) return;
+ // Перерисовываем с pressed=true, через 100мс — обратно.
+ const dyn = data.mesh?.metadata?._billboardTexture;
+ if (!dyn) return;
+ this._render(dyn, data.billboard.template, data.billboard.content,
+ data.billboard.elements, /* pressed */ buttonId);
+ setTimeout(() => {
+ if (data.mesh?.metadata?._billboardTexture === dyn) {
+ this._render(dyn, data.billboard.template, data.billboard.content,
+ data.billboard.elements, null);
+ }
+ }, 120);
+ }
+
+ _defaultContent(template) {
+ switch (template) {
+ case 'shop-item':
+ return { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
+ price: '$100', gradient: ['#ff5a5a', '#ff8a3d'] };
+ case 'shop-purchase':
+ return { icon: 'seed', title: 'Набор семян', sub: 'x3',
+ price: '199 R', gradient: ['#3b82f6', '#0ea5e9'] };
+ case 'banner':
+ return { title: 'Удвоенный урожай в 17:00',
+ gradient: ['#7c3aed', '#a855f7'] };
+ case 'sign':
+ return { title: 'Сюда', gradient: ['#1f2937', '#374151'] };
+ default:
+ return { title: 'Табличка', gradient: ['#1f2937', '#374151'] };
+ }
+ }
+
+ /** Главная функция рендера — рисует контент на canvas DynamicTexture. */
+ _render(dyn, template, content, elements, pressedButtonId) {
+ const ctx = dyn.getContext();
+ ctx.clearRect(0, 0, TEXTURE_W, TEXTURE_H);
+
+ if (elements && Array.isArray(elements)) {
+ // Кастомный режим — рисуем массив элементов
+ this._renderElements(ctx, elements, pressedButtonId);
+ } else {
+ switch (template) {
+ case 'shop-item':
+ this._renderShopItem(ctx, content, pressedButtonId);
+ break;
+ case 'shop-purchase':
+ this._renderShopPurchase(ctx, content, pressedButtonId);
+ break;
+ case 'banner':
+ this._renderBanner(ctx, content);
+ break;
+ case 'sign':
+ this._renderSign(ctx, content);
+ break;
+ default:
+ this._renderBanner(ctx, content);
+ }
+ }
+ dyn.update(false /* invertY */);
+ }
+
+ /** Скруглённый прямоугольник + заливка градиентом + обводка. */
+ _roundedGradientRect(ctx, x, y, w, h, opts) {
+ const r = opts.radius ?? 24;
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+ const grad = ctx.createLinearGradient(x, y, x, y + h);
+ const [from, to] = opts.gradient || ['#333', '#111'];
+ grad.addColorStop(0, from);
+ grad.addColorStop(1, to);
+ ctx.fillStyle = grad;
+ ctx.fill();
+ if (opts.stroke) {
+ ctx.lineWidth = opts.stroke.width ?? 3;
+ ctx.strokeStyle = opts.stroke.color || '#000';
+ ctx.stroke();
+ }
+ }
+
+ /** Рендер пресета shop-item: иконка слева | title + sub | кнопка цены. */
+ _renderShopItem(ctx, content, pressedButtonId) {
+ // Главная плашка
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#ff5a5a', '#ff8a3d'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ // Иконка-слот: круг чуть темнее в левой части
+ ctx.beginPath();
+ ctx.arc(110, 130, 70, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
+ ctx.fill();
+
+ // Сама иконка (emoji крупно)
+ const iconChar = ICONS[content.icon] || ICONS.cube;
+ ctx.font = 'bold 96px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(iconChar, 110, 132);
+
+ // Заголовок
+ ctx.font = 'bold 36px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = '#fff';
+ // Лёгкая тень
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
+ ctx.shadowBlur = 4;
+ ctx.shadowOffsetY = 2;
+ ctx.fillText(this._truncate(content.title || '', 18), 200, 50);
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetY = 0;
+
+ // Подзаголовок "1 > 2" — зелёный, поменьше
+ if (content.sub) {
+ ctx.font = 'bold 28px Arial, sans-serif';
+ ctx.fillStyle = '#a7f3d0';
+ ctx.fillText(content.sub, 200, 105);
+ }
+
+ // Кнопка цены — жёлтый прямоугольник внизу справа
+ const b = SHOP_ITEM_BUTTON;
+ const pressed = pressedButtonId === 'buy';
+ this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
+ gradient: pressed
+ ? ['#d97706', '#92400e']
+ : ['#fbbf24', '#f59e0b'],
+ radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = 'bold 36px Arial, sans-serif';
+ ctx.fillStyle = pressed ? '#fef3c7' : '#1c1917';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(content.price || '$0', b.x + b.w / 2, b.y + b.h / 2);
+ }
+
+ /** Рендер пресета shop-purchase: похож на shop-item, но иконка крупнее и центрирована. */
+ _renderShopPurchase(ctx, content, pressedButtonId) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#3b82f6', '#0ea5e9'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ const iconChar = ICONS[content.icon] || ICONS.bag;
+ ctx.font = 'bold 110px "Segoe UI Emoji", Arial';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(iconChar, 110, 140);
+
+ ctx.font = 'bold 34px Arial, sans-serif';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(this._truncate(content.title || '', 16), 200, 50);
+
+ if (content.sub) {
+ ctx.font = 'bold 26px Arial, sans-serif';
+ ctx.fillStyle = '#dbeafe';
+ ctx.fillText(content.sub, 200, 100);
+ }
+
+ // Кнопка-цена
+ const b = SHOP_ITEM_BUTTON;
+ const pressed = pressedButtonId === 'buy';
+ this._roundedGradientRect(ctx, b.x, b.y, b.w, b.h, {
+ gradient: pressed
+ ? ['#9333ea', '#6b21a8']
+ : ['#a855f7', '#7c3aed'],
+ radius: 16,
+ stroke: { color: '#000', width: 3 },
+ });
+ ctx.font = 'bold 34px Arial, sans-serif';
+ ctx.fillStyle = '#fff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(content.price || '0 R', b.x + b.w / 2, b.y + b.h / 2);
+ }
+
+ /** Рендер пресета banner: одна крупная фраза по центру. */
+ _renderBanner(ctx, content) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#7c3aed', '#a855f7'],
+ radius: 28,
+ stroke: { color: '#0008', width: 4 },
+ });
+
+ ctx.font = 'bold 46px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
+ ctx.shadowBlur = 8;
+ ctx.shadowOffsetY = 3;
+
+ // Перенос строк, чтобы длинная фраза влезла
+ const lines = this._wrapText(ctx, content.title || '', TEXTURE_W - 80);
+ const lh = 56;
+ const startY = TEXTURE_H / 2 - (lines.length - 1) * lh / 2;
+ for (let i = 0; i < lines.length; i++) {
+ ctx.fillText(lines[i], TEXTURE_W / 2, startY + i * lh);
+ }
+ ctx.shadowColor = 'transparent';
+ ctx.shadowBlur = 0;
+ ctx.shadowOffsetY = 0;
+ }
+
+ /** Рендер пресета sign: компактный указатель. */
+ _renderSign(ctx, content) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: content.gradient || ['#1f2937', '#374151'],
+ radius: 20,
+ stroke: { color: '#fff', width: 4 },
+ });
+
+ ctx.font = 'bold 64px Arial, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#fff';
+ ctx.fillText(this._truncate(content.title || '', 14), TEXTURE_W / 2, TEXTURE_H / 2);
+ }
+
+ /** Рендер кастомного списка элементов: фон + список text/image/button.
+ * Каждый элемент: { kind, x, y, w, h, ... }
+ * text: { text, size, color, bold, align }
+ * image: { src (icon-key), w, h }
+ * button: { id, text, background: {color|gradient, cornerRadius, stroke} }
+ */
+ _renderElements(ctx, elements, pressedButtonId) {
+ // Фоновая плашка — первый элемент типа 'background' (опционально)
+ const bg = elements.find(e => e.kind === 'background');
+ if (bg) {
+ this._roundedGradientRect(ctx, 8, 8, TEXTURE_W - 16, TEXTURE_H - 16, {
+ gradient: bg.gradient || ['#1f2937', '#374151'],
+ radius: bg.cornerRadius ?? 24,
+ stroke: bg.stroke || { color: '#0008', width: 4 },
+ });
+ }
+ // Остальные элементы — поверх фона
+ for (const el of elements) {
+ if (el.kind === 'background') continue;
+ if (el.kind === 'text') {
+ ctx.font = `${el.bold ? 'bold ' : ''}${el.size || 24}px Arial, sans-serif`;
+ ctx.fillStyle = el.color || '#fff';
+ ctx.textAlign = el.align || 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(el.text || '', el.x || 0, el.y || 0);
+ } else if (el.kind === 'image') {
+ const iconChar = ICONS[el.src] || ICONS.cube;
+ const size = Math.min(el.w || 64, el.h || 64);
+ ctx.font = `${Math.round(size * 1.1)}px "Segoe UI Emoji", Arial`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = el.color || '#fff';
+ ctx.fillText(iconChar, (el.x || 0) + (el.w || 64) / 2,
+ (el.y || 0) + (el.h || 64) / 2);
+ } else if (el.kind === 'button') {
+ const isPressed = pressedButtonId === el.id;
+ const bgSpec = el.background || {};
+ this._roundedGradientRect(ctx, el.x || 0, el.y || 0,
+ el.w || 100, el.h || 36, {
+ gradient: bgSpec.gradient ||
+ (bgSpec.color ? [bgSpec.color, bgSpec.color] : ['#fbbf24', '#f59e0b']),
+ radius: bgSpec.cornerRadius ?? 12,
+ stroke: bgSpec.stroke || { color: '#000', width: 2 },
+ });
+ ctx.font = `bold ${el.textSize || 28}px Arial, sans-serif`;
+ ctx.fillStyle = isPressed ? '#fef3c7' : (el.textColor || '#1c1917');
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(el.text || '', (el.x || 0) + (el.w || 100) / 2,
+ (el.y || 0) + (el.h || 36) / 2);
+ }
+ }
+ }
+
+ /** Hit-тест для кастомных elements (используется в pickButtonAt). */
+ _hitTestElements(elements, px, py) {
+ for (const el of elements) {
+ if (el.kind !== 'button') continue;
+ const x = el.x || 0, y = el.y || 0;
+ const w = el.w || 100, h = el.h || 36;
+ if (px >= x && px <= x + w && py >= y && py <= y + h) {
+ return el.id || null;
+ }
+ }
+ return null;
+ }
+
+ _truncate(s, max) {
+ if (!s) return '';
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
+ }
+
+ _wrapText(ctx, text, maxWidth) {
+ const words = (text || '').split(' ');
+ const lines = [];
+ let cur = '';
+ for (const w of words) {
+ const test = cur ? cur + ' ' + w : w;
+ if (ctx.measureText(test).width <= maxWidth) {
+ cur = test;
+ } else {
+ if (cur) lines.push(cur);
+ cur = w;
+ }
+ }
+ if (cur) lines.push(cur);
+ return lines.slice(0, 3); // максимум 3 строки
+ }
+
+ /** Список доступных иконок (для UI редактора). */
+ static getAvailableIcons() {
+ return Object.keys(ICONS);
+ }
+
+ /** Список доступных пресетов (для UI редактора). */
+ static getAvailableTemplates() {
+ return [
+ { id: 'shop-item', name: 'Магазин: апгрейд', hasButton: true,
+ fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
+ { id: 'shop-purchase', name: 'Магазин: покупка', hasButton: true,
+ fields: ['icon', 'title', 'sub', 'price', 'gradient'] },
+ { id: 'banner', name: 'Баннер', hasButton: false,
+ fields: ['title', 'gradient'] },
+ { id: 'sign', name: 'Указатель', hasButton: false,
+ fields: ['title', 'gradient'] },
+ ];
+ }
+}
diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js
index 28d6850..a1f4243 100644
--- a/src/editor/engine/GameRuntime.js
+++ b/src/editor/engine/GameRuntime.js
@@ -2670,6 +2670,57 @@ export class GameRuntime {
}
return;
}
+ // === Billboard 3D-таблички (см. BillboardUiManager) ===
+ if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') {
+ try {
+ // Резолв ref → primitiveId
+ let ref = payload?.ref;
+ if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref);
+ // ref имеет формат 'primitive:NN' — выделяем числовой id
+ let id = null;
+ if (typeof ref === 'string' && ref.startsWith('primitive:')) {
+ id = Number(ref.slice('primitive:'.length));
+ } else if (Number.isFinite(ref)) {
+ id = Number(ref);
+ }
+ if (!Number.isFinite(id) || id == null) return;
+ const data = this.scene3d?.primitiveManager?.instances?.get(id);
+ if (!data || data.type !== 'billboard') return;
+ const mgr = this.scene3d?.billboardUiManager;
+ if (!mgr) return;
+
+ if (cmd === 'billboard.set') {
+ mgr.applyToMesh(data, {
+ template: payload.template || data.billboard?.template || 'shop-item',
+ face: payload.face || data.billboard?.face || 'camera',
+ content: payload.content || data.billboard?.content,
+ elements: payload.elements || data.billboard?.elements,
+ });
+ this.scheduleSceneSnapshot?.();
+ } else if (cmd === 'billboard.update') {
+ mgr.update(data, payload.patch || {});
+ this.scheduleSceneSnapshot?.();
+ } else if (cmd === 'billboard.onClick') {
+ const buttonId = String(payload.buttonId || 'buy');
+ // Регистрируем handler: при клике эмитим event в worker,
+ // worker найдёт зарегистрированный JS-callback по (ref,button).
+ const realRef = 'primitive:' + id;
+ mgr.onClick(data, buttonId, () => {
+ const sb = this.sandboxes.find(s => s.scriptId === scriptId);
+ if (sb && typeof sb.sendEvent === 'function') {
+ sb.sendEvent({
+ type: 'billboardClick',
+ ref: realRef,
+ button: buttonId,
+ });
+ }
+ });
+ }
+ } catch (e) {
+ this._log('error', cmd + ' failed: ' + (e?.message || e));
+ }
+ return;
+ }
if (cmd === 'gui.update') {
// payload: { id, patch }
try {
@@ -2812,6 +2863,11 @@ export class GameRuntime {
effect: opts.effect,
// textureAsset — картинка из ассетов проекта на грани.
...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}),
+ // billboard-параметры (только для type='billboard')
+ ...(opts.template != null ? { template: opts.template } : {}),
+ ...(opts.face != null ? { face: opts.face } : {}),
+ ...(opts.content != null ? { content: opts.content } : {}),
+ ...(opts.elements != null ? { elements: opts.elements } : {}),
// anchored:false → объект падает (физика unanchored).
// canCollide:false → проходимый (зона-триггер).
...(opts.anchored != null ? { anchored: opts.anchored } : {}),
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js
index f34cfaa..e8aac7c 100644
--- a/src/editor/engine/PrimitiveManager.js
+++ b/src/editor/engine/PrimitiveManager.js
@@ -145,6 +145,20 @@ export class PrimitiveManager {
}
}
+ // === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
+ if (typeDef.kind === 'billboard' && this.billboardUiManager) {
+ // Сохраняем настройки билборда в data.billboardOpts чтобы
+ // serialize мог записать их обратно в JSON проекта.
+ const billboardOpts = {
+ template: opts.template || 'shop-item',
+ face: opts.face || 'camera',
+ content: opts.content || null,
+ elements: opts.elements || null,
+ };
+ this.billboardUiManager.applyToMesh(data, billboardOpts);
+ // billboardOpts хранится в data.billboard после applyToMesh.
+ }
+
this.instances.set(id, data);
// Авто-регистрация в shadow casters (Этап 4 теней).
try {
@@ -197,6 +211,13 @@ export class PrimitiveManager {
// создаются отдельно в addInstance.
return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
+ case 'billboard':
+ // 3D-табличка — плоскость с пропорциями таблички (sx × sy),
+ // sz используется как «толщина рамки» (визуально-незаметная).
+ // Использует CreatePlane для одностороннего рендера, но в
+ // BillboardUiManager backFaceCulling=false → видно с обеих сторон.
+ return MeshBuilder.CreatePlane(name,
+ { width: sx, height: sy, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
case 'plane':
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene);
@@ -590,6 +611,11 @@ export class PrimitiveManager {
}
}
+ // Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
+ if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
+ this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
+ }
+
// === Лампа: синхронизируем привязанный PointLight ===
if (data.light) {
// позиция света — за маркером
@@ -722,6 +748,13 @@ export class PrimitiveManager {
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter')
...(d.effect !== undefined ? { effect: d.effect } : {}),
+ // Параметры билборда (только для type='billboard')
+ ...(d.billboard ? {
+ template: d.billboard.template,
+ face: d.billboard.face,
+ content: d.billboard.content,
+ ...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
+ } : {}),
}));
}
diff --git a/src/editor/engine/PrimitiveTypes.js b/src/editor/engine/PrimitiveTypes.js
index 42a9728..cdbafe8 100644
--- a/src/editor/engine/PrimitiveTypes.js
+++ b/src/editor/engine/PrimitiveTypes.js
@@ -57,6 +57,15 @@ export const PRIMITIVE_TYPES = [
{ id: 'emitter', name: 'Эмиттер частиц', icon: 'prim-emitter', kind: 'emitter',
defaultScale: { x: 0.4, y: 0.4, z: 0.4 }, defaultColor: '#ff8833' },
+ // === Табличка — 3D-карточка с GUI (как BillboardGui в Roblox) ===
+ // Плоскость с натянутой DynamicTexture, на которой рендерится контент
+ // (заголовок, иконка, кнопка). Поддерживает 4 пресета (см. BillboardUiManager):
+ // shop-item, shop-purchase, banner, sign. Может смотреть на камеру
+ // (face=camera) или быть фиксированной (face=fixed). Клик по кнопке
+ // эмитит событие через game.billboard.onClick.
+ { id: 'billboard', name: '3D-табличка', icon: 'prim-billboard', kind: 'billboard',
+ defaultScale: { x: 2, y: 1.2, z: 0.05 }, defaultColor: '#1a1a2e' },
+
// === GD-порталы (Geometry Dash 2.0) — переключают гейммод игрока ===
// Все цилиндры-«столбы» с neon-материалом. Цвета и gdMode задают режим.
{ id: 'gd_cube', name: 'Портал: Куб', icon: 'prim-portal', kind: 'gd_portal', gdMode: 'cube',
@@ -87,7 +96,7 @@ export const PRIMITIVE_TYPES = [
/** Категории для группировки в палитре. */
export const PRIMITIVE_CATEGORIES = [
{ id: 'geometry', name: 'Фигуры', ids: ['cube','sphere','cylinder','cone','plane','torus','wedge','cornerwedge'] },
- { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter'] },
+ { id: 'gameplay', name: 'Геймплей', ids: ['trigger', 'checkpoint', 'light', 'emitter', 'billboard'] },
{ id: 'gd-portals', name: 'GD-порталы', ids: ['gd_cube','gd_ship','gd_ball','gd_ufo','gd_wave','gd_robot'] },
{ id: 'gd-objects', name: 'GD-объекты', ids: ['gd_spike','gd_finish','gd_coin'] },
];
diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js
index 8ea6fc9..04542db 100644
--- a/src/editor/engine/ScriptSandboxWorker.js
+++ b/src/editor/engine/ScriptSandboxWorker.js
@@ -103,6 +103,12 @@ let _guiIndex = [];
let _guiClickHandlers = {};
// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter)
let _guiSubmitHandlers = {};
+// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке
+// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка
+// из game.scene.spawn() или game.scene.findOne() в формате
+// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime),
+// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'.
+let _billboardClickHandlers = {};
// Для GUI-события с реальным id вернуть набор ключей, под которыми
// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт
// часто подписывается через game.gui.onClick('ИмяКнопки', fn)).
@@ -2644,6 +2650,74 @@ const game = {
_send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') });
},
},
+ /**
+ * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox).
+ * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}),
+ * затем настраиваются через game.billboard.set/update.
+ *
+ * Пресеты (template):
+ * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены
+ * - 'shop-purchase' — иконка + название + цена (для покупки)
+ * - 'banner' — крупный текст
+ * - 'sign' — простой указатель
+ *
+ * Пример (4 таблички-апгрейды):
+ * const refs = ['vis','range','saws','sprink'].map((kind, i) => {
+ * return game.scene.spawn('billboard', {
+ * x: -6 + i*4, y: 3, z: 5,
+ * template: 'shop-item',
+ * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2',
+ * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] },
+ * });
+ * });
+ * game.billboard.onClick(refs[0], 'buy', () => {
+ * game.ui.showText('Куплено!');
+ * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' });
+ * });
+ */
+ billboard: {
+ /**
+ * Полная замена контента таблички. Если пресет тот же — мгновенно
+ * перерисует. Если template другой — пересоздаст текстуру.
+ * ref — string-ref из game.scene.spawn() или game.scene.findOne()
+ * opts — { template?, face?, content?, elements? }
+ */
+ set(ref, opts) {
+ if (!ref || typeof opts !== 'object' || opts == null) return;
+ _send('billboard.set', { ref, ...opts });
+ },
+ /**
+ * Частичное обновление content. Самое частое — после клика поменять
+ * sub-строку и цену.
+ * patch — частичный content: { sub, price, title, icon, gradient }
+ */
+ update(ref, patch) {
+ if (!ref || typeof patch !== 'object' || patch == null) return;
+ _send('billboard.update', { ref, patch });
+ },
+ /**
+ * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy';
+ * в кастомных elements — id из элемента kind='button').
+ * ref — string-ref
+ * buttonId — id кнопки (по умолчанию 'buy')
+ * fn — () => void
+ */
+ onClick(ref, buttonId, fn) {
+ if (typeof fn !== 'function') {
+ // Поддержка вызова с 2 аргументами — buttonId по умолчанию 'buy'.
+ fn = buttonId;
+ buttonId = 'buy';
+ }
+ if (!ref || typeof fn !== 'function') return;
+ const bid = String(buttonId || 'buy');
+ const key = ref + ':' + bid;
+ if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = [];
+ _billboardClickHandlers[key].push(fn);
+ // Уведомляем main о подписке (чтобы он зарегистрировал hit-listener
+ // в BillboardUiManager и слал нам billboardClick события).
+ _send('billboard.onClick', { ref, buttonId: bid });
+ },
+ },
/**
* Управление режимами ввода — курсор и камера.
* В режиме 'ui' мышь работает как обычный курсор (как в браузере),
@@ -3187,6 +3261,25 @@ self.onmessage = (e) => {
const arr = _guiSubmitHandlers[key] || [];
for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key);
}
+ } else if (t === 'billboardClick') {
+ // payload: { ref, button } — клик по кнопке 3D-таблички.
+ // Ищем handlers и по реальному ref (primitive:NN), и по локальному
+ // ref если такой есть (на случай если скрипт подписался по
+ // локальному ref от scene.spawn).
+ const realRef = String(payload.ref || '');
+ const button = String(payload.button || 'buy');
+ const tryKeys = [realRef + ':' + button];
+ // Если есть локальный ref, ведущий к этому real — тоже попробуем
+ // (скрипт мог подписаться на ref сразу после game.scene.spawn,
+ // когда ref был ещё локальным _local_N).
+ for (const [local, real] of Object.entries(_spawnLocalToReal || {})) {
+ if (real === realRef) tryKeys.push(local + ':' + button);
+ }
+ for (const key of tryKeys) {
+ const arr = _billboardClickHandlers[key] || [];
+ for (const fn of arr) _safeCall(fn, { ref: realRef, button },
+ 'billboard.onClick:' + key);
+ }
}
} else if (cmd === 'sceneSnapshot') {
// payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? }
@@ -3267,6 +3360,7 @@ self.onmessage = (e) => {
_messageHandlers = {};
_guiClickHandlers = {};
_guiSubmitHandlers = {};
+ _billboardClickHandlers = {};
_npcDeathHandlers = {};
_globalNpcDeathHandlers = [];
_npcLocalToReal = {};