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') && ( +
+ setTitle(e.target.value)} + maxLength={80} + style={inputStyle}/> +
+ )} + + {fields.includes('sub') && ( +
+ setSub(e.target.value)} + maxLength={40} + placeholder='Например: "1 > 2"' + style={inputStyle}/> +
+ )} + + {fields.includes('price') && ( +
+ setPrice(e.target.value)} + maxLength={20} + placeholder='Например: $10,000' + style={inputStyle}/> +
+ )} + + {fields.includes('gradient') && ( +
+
+ {GRADIENT_PRESETS.map(g => ( +
+
+ + setGradFrom(e.target.value)} + style={{ width: 50, height: 28 }}/> + + setGradTo(e.target.value)} + style={{ width: 50, height: 28 }}/> +
+
+ )} +
+ + {/* Правая колонка — превью */} +
+
Превью:
+
+ +
+
+ {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 = {};